From e9c87e4c6bef5802b20e67ae39f865e9ef2a8f81 Mon Sep 17 00:00:00 2001 From: Ladme Date: Sat, 16 May 2026 16:29:52 +0200 Subject: [PATCH 01/20] Automatic creation of an .init file in loop jobs and other changes from array-jobs branch --- .gitignore | 5 +- .readthedocs.yaml | 16 ++-- CHANGELOG.md | 5 ++ src/qq_lib/archive/archiver.py | 24 +++-- src/qq_lib/core/command_runner.py | 2 +- src/qq_lib/core/common.py | 4 +- src/qq_lib/properties/resubmit_host.py | 2 +- src/qq_lib/run/runner.py | 36 +++++++- src/qq_lib/submit/submitter.py | 12 ++- tests/archive/test_archive.py | 85 +++++++++++++++--- tests/run/test_run_runner.py | 120 +++++++++++++++++++++++-- 11 files changed, 270 insertions(+), 41 deletions(-) diff --git a/.gitignore b/.gitignore index a698b67..e3701aa 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,7 @@ htmlcov/ docs/ # Version -src/qq_lib/_version.py \ No newline at end of file +src/qq_lib/_version.py + +# Playground +src/_playground*.py diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 144c51c..d8b71fb 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,11 +1,11 @@ version: 2 build: - os: ubuntu-22.04 - tools: - python: "3.12" - commands: - - pip install uv - - uv pip install --system pdoc - - uv pip install --system . - - pdoc src/qq_lib -d google -o $READTHEDOCS_OUTPUT/html \ No newline at end of file + os: ubuntu-22.04 + tools: + python: "3.13" + commands: + - pip install uv + - uv pip install --system pdoc + - uv pip install --system . + - pdoc src/qq_lib -d google -o $READTHEDOCS_OUTPUT/html diff --git a/CHANGELOG.md b/CHANGELOG.md index ce958e8..b6f540a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## Version 0.12.0 +- If a loop job does not create any archive files for the next cycle of the loop job, qq creates an empty `.init` file fulfilling the loop job's requirements. + +*** + ## Version 0.11 ### qq respawn - Failed or killed jobs can now be easily "respawned" using `qq respawn`. When respawning a job, qq will remove the working directory of the failed job, clear all runtime files, and resubmit the job with the same parameters as before. diff --git a/src/qq_lib/archive/archiver.py b/src/qq_lib/archive/archiver.py index d8b8448..105d0d7 100644 --- a/src/qq_lib/archive/archiver.py +++ b/src/qq_lib/archive/archiver.py @@ -74,7 +74,7 @@ def from_archive(self, dir: Path, cycle: int | None = None) -> None: QQError: If file transfer fails. """ if not ( - files := self._get_files( + files := self.get_files_matching_pattern( self._archive, self._input_machine, self._archive_format, cycle, False ) ): @@ -108,7 +108,11 @@ def to_archive(self, dir: Path) -> None: Raises: QQError: If file transfer or removal fails. """ - if not (files := self._get_files(dir, None, self._archive_format, None, False)): + if not ( + files := self.get_files_matching_pattern( + dir, None, self._archive_format, None, False + ) + ): logger.debug("Nothing to archive.") return @@ -153,7 +157,7 @@ def archive_runtime_files(self, job_name: str, cycle: int) -> None: QQError: If moving the runtime files fails. """ if not ( - files := self._get_files( + files := self.get_files_matching_pattern( self._input_dir, self._input_machine, # only use the stem of the job name, the extension will not be matched @@ -183,7 +187,7 @@ def archive_runtime_files(self, job_name: str, cycle: int) -> None: wait_seconds=CFG.archiver.retry_wait, ).run() - def _get_files( + def get_files_matching_pattern( self, directory: Path, host: str | None, @@ -205,7 +209,7 @@ def _get_files( include_qq_files (bool): Whether to include qq runtime files. Defaults to False. Returns: - list[Path]: A list of absolute paths to matching files. + list[Path]: A list of absolute (logical) paths to matching files. """ if cycle and is_printf_pattern(pattern): try: @@ -248,6 +252,16 @@ def _get_files( if regex.search(f.stem) and f.suffix not in CFG.suffixes.all_suffixes ] + def create_init_file(self, cycle: int) -> None: + """ + Create an empty init file for the given cycle. + Used as a fallback when no valid archive file is produced, ensuring + the next iteration of the loop job can proceed normally. + Args: + cycle (int): The index of the next cycle of the loop job. + """ + Path(f"{self._archive_format % cycle}.init").touch() + @staticmethod def _prepare_regex_pattern(pattern: str) -> re.Pattern[str]: """ diff --git a/src/qq_lib/core/command_runner.py b/src/qq_lib/core/command_runner.py index 5a288bd..3612cd8 100644 --- a/src/qq_lib/core/command_runner.py +++ b/src/qq_lib/core/command_runner.py @@ -220,7 +220,7 @@ def prepare(index: int, target: Callable[[], Informer]) -> None: self._execute(result) else: raise ValueError( - f"Unexpected result type: {type(result)}. This is a bug, please repport it." + f"Unexpected result type: {type(result)}. This is a bug, please report it." ) def _execute(self, informer: Informer) -> None: diff --git a/src/qq_lib/core/common.py b/src/qq_lib/core/common.py index 4593fc5..454e0a9 100644 --- a/src/qq_lib/core/common.py +++ b/src/qq_lib/core/common.py @@ -99,7 +99,7 @@ def get_info_file(directory: Path) -> Path: """ Locate the qq job info file in a directory. - This function searches for files matching the `QQ_INFO_SUFFIX` in the + This function searches for files with suffix `CFG.suffixes.qq_info` in the provided directory. It raises an error if none or multiple info files are found. Args: @@ -124,7 +124,7 @@ def get_info_files(directory: Path) -> list[Path]: """ Retrieve all qq job info files in a directory. - This function searches for files matching the `QQ_INFO_SUFFIX` in the + This function searches for files with suffix `CFG.suffixes.qq_info` in the provided directory. The files are sorted by their last modification time (with the newest modified file being last in the list). diff --git a/src/qq_lib/properties/resubmit_host.py b/src/qq_lib/properties/resubmit_host.py index 4a0ca3e..841e116 100644 --- a/src/qq_lib/properties/resubmit_host.py +++ b/src/qq_lib/properties/resubmit_host.py @@ -19,7 +19,7 @@ class ResubmitHost(ABC): Subclasses: InputHost: Resolves to the original input machine. WorkHost: Resolves to the current working node. - CustomHost: Resolves to an explicitly specified hostname. + ExplicitHost: Resolves to an explicitly specified hostname. """ @classmethod diff --git a/src/qq_lib/run/runner.py b/src/qq_lib/run/runner.py index 4d71ac4..73b15c0 100644 --- a/src/qq_lib/run/runner.py +++ b/src/qq_lib/run/runner.py @@ -271,7 +271,7 @@ def finalize(self) -> None: logger.debug( f"Script exit code is '{self._process.returncode}'. Archiving files." ) - self._archiver.to_archive(self._work_dir) + self._archive_files_from_work_dir() # transfer files back to the input (submission) directory if self._use_scratch: @@ -690,7 +690,7 @@ def _resubmit(self) -> None: if self._informer.info.job_type == JobType.LOOP: if not (loop_info := self._informer.info.loop_info): - logger.warning( + raise QQError( "Loop info is undefined while resubmiting a loop job. This is a bug!" ) return @@ -707,6 +707,38 @@ def _resubmit(self) -> None: logger.info(f"Job resubmitted successfully as '{job_id}'.") + def _archive_files_from_work_dir(self) -> None: + """ + Archive files from the working directory. + + If no file exists for the next loop cycle, creates an empty init file to ensure the loop job continues normally. + """ + if not self._archiver: + raise QQError("Archiver is undefined while archiving files. This is a bug!") + + if not (loop_info := self._informer.info.loop_info): + raise QQError( + "Loop info is undefined while archiving files. This is a bug!" + ) + + # get the files to archive corresponding to the next loop job cycle + if not self._archiver.get_files_matching_pattern( + self._work_dir, + None, + loop_info.archive_format, + loop_info.current + 1, + False, + ): + # if there are no files matching the next loop job cycle, create an empty .init file + # so that the loop job continues normally + logger.debug( + f"Creating .init file for loop job cycle {loop_info.current + 1}." + ) + self._archiver.create_init_file(loop_info.current + 1) + + # archive all files matching the archive format + self._archiver.to_archive(self._work_dir) + def _get_explicitly_included_files_in_work_dir(self) -> list[Path]: """ Return absolute paths to files and directories in the working directory diff --git a/src/qq_lib/submit/submitter.py b/src/qq_lib/submit/submitter.py index a5ee2d3..ee04af9 100644 --- a/src/qq_lib/submit/submitter.py +++ b/src/qq_lib/submit/submitter.py @@ -275,6 +275,10 @@ def get_batch_system(self) -> AnyBatchClass: """Get the batch system used for submiting.""" return self._batch_system + def get_job_name(self) -> str: + """Get the name of the job.""" + return self._job_name + def get_queue(self) -> str: """Get the submission queue.""" return self._queue @@ -284,7 +288,7 @@ def get_account(self) -> str | None: return self._account def get_script(self) -> Path: - """Get path to the submitted script.""" + """Get absolute (logical) path to the submitted script.""" return self._script def get_job_type(self) -> JobType: @@ -299,15 +303,15 @@ def get_loop_info(self) -> LoopInfo | None: """Get loop job information.""" return self._loop_info - def get_exclude(self) -> list[Path] | None: + def get_exclude(self) -> list[Path]: """Get a list of excluded files.""" return self._exclude - def get_include(self) -> list[Path] | None: + def get_include(self) -> list[Path]: """Get a list of included files.""" return self._include - def get_depend(self) -> list[Depend] | None: + def get_depend(self) -> list[Depend]: """Get the list of dependencies.""" return self._depend diff --git a/tests/archive/test_archive.py b/tests/archive/test_archive.py index 6016cf8..077795f 100644 --- a/tests/archive/test_archive.py +++ b/tests/archive/test_archive.py @@ -146,7 +146,9 @@ def test_get_files_printf_pattern_with_cycle(monkeypatch, archiver, input_dir, h filenames = ["job0001.dat", "job0002.dat", "job0001.qqout", "job0001.err"] touch_files(input_dir, filenames) - result = archiver._get_files(input_dir, host=host, pattern="job%04d", cycle=1) + result = archiver.get_files_matching_pattern( + input_dir, host=host, pattern="job%04d", cycle=1 + ) expected = [input_dir / "job0001.dat"] assert set(result) == {f.resolve() for f in expected} @@ -159,7 +161,9 @@ def test_get_files_printf_pattern_with_cycle_partial_match( filenames = ["job0001_px.dat", "job0002.dat", "job0001.qqout", "job0001.err"] touch_files(input_dir, filenames) - result = archiver._get_files(input_dir, host=host, pattern="job%04d", cycle=1) + result = archiver.get_files_matching_pattern( + input_dir, host=host, pattern="job%04d", cycle=1 + ) expected = [input_dir / "job0001_px.dat"] assert set(result) == {f.resolve() for f in expected} @@ -171,14 +175,14 @@ def test_get_files_include_qq_files(monkeypatch, archiver, input_dir, host): touch_files(input_dir, filenames) # include_qq_files=False: QQ_SUFFIXES filtered out - result = archiver._get_files( + result = archiver.get_files_matching_pattern( input_dir, host=host, pattern="job%04d", cycle=1, include_qq_files=False ) expected = [input_dir / "job0001.dat"] assert set(result) == {f.resolve() for f in expected} # include_qq_files=True: QQ_SUFFIXES included - result2 = archiver._get_files( + result2 = archiver.get_files_matching_pattern( input_dir, host=host, pattern="job%04d", cycle=1, include_qq_files=True ) expected2 = [input_dir / f for f in filenames] @@ -191,7 +195,9 @@ def test_get_files_cycle_not_matching(monkeypatch, archiver, input_dir, host): filenames = ["job0001.dat", "job0002.dat"] touch_files(input_dir, filenames) - result = archiver._get_files(input_dir, host=host, pattern="job%04d", cycle=3) + result = archiver.get_files_matching_pattern( + input_dir, host=host, pattern="job%04d", cycle=3 + ) assert result == [] @@ -201,7 +207,9 @@ def test_get_files_regex_pattern(monkeypatch, archiver, input_dir, host): filenames = ["data_01.txt", "data_02.txt", "job0001.dat"] touch_files(input_dir, filenames) - result = archiver._get_files(input_dir, host=host, pattern=r"data_\d\d") + result = archiver.get_files_matching_pattern( + input_dir, host=host, pattern=r"data_\d\d" + ) expected = [input_dir / "data_01.txt", input_dir / "data_02.txt"] assert set(result) == {f.resolve() for f in expected} @@ -212,7 +220,9 @@ def test_get_files_regex_pattern_with_cycle(monkeypatch, archiver, input_dir, ho filenames = ["data_01.txt", "data_02.txt", "job0001.dat"] touch_files(input_dir, filenames) - result = archiver._get_files(input_dir, host=host, pattern=r"data_\d\d", cycle=2) + result = archiver.get_files_matching_pattern( + input_dir, host=host, pattern=r"data_\d\d", cycle=2 + ) expected = [input_dir / "data_01.txt", input_dir / "data_02.txt"] assert set(result) == {f.resolve() for f in expected} @@ -225,7 +235,9 @@ def test_get_files_regex_pattern_with_cycle_partial_match( filenames = ["data_01.txt", "data_02_px.txt", "job0001.dat"] touch_files(input_dir, filenames) - result = archiver._get_files(input_dir, host=host, pattern=r"data_\d\d", cycle=2) + result = archiver.get_files_matching_pattern( + input_dir, host=host, pattern=r"data_\d\d", cycle=2 + ) expected = [input_dir / "data_01.txt", input_dir / "data_02_px.txt"] assert set(result) == {f.resolve() for f in expected} @@ -238,11 +250,13 @@ def test_get_files_printf_pattern_without_cycle(monkeypatch, archiver, input_dir for f in filenames: (input_dir / f).touch() - result = archiver._get_files(input_dir, host=host, pattern="job%04d", cycle=None) + result = archiver.get_files_matching_pattern( + input_dir, host=host, pattern="job%04d", cycle=None + ) expected = [input_dir / "job0001.dat", input_dir / "job0002.dat"] assert set(result) == {f.resolve() for f in expected} - result = archiver._get_files( + result = archiver.get_files_matching_pattern( input_dir, host=host, pattern="job%04d", cycle=None, include_qq_files=True ) expected = [ @@ -263,11 +277,13 @@ def test_get_files_printf_pattern_without_cycle_partial_match( for f in filenames: (input_dir / f).touch() - result = archiver._get_files(input_dir, host=host, pattern="job%04d", cycle=None) + result = archiver.get_files_matching_pattern( + input_dir, host=host, pattern="job%04d", cycle=None + ) expected = [input_dir / "job0001.dat", input_dir / "job0002_px.dat"] assert set(result) == {f.resolve() for f in expected} - result = archiver._get_files( + result = archiver.get_files_matching_pattern( input_dir, host=host, pattern="job%04d", cycle=None, include_qq_files=True ) expected = [ @@ -477,3 +493,48 @@ def test_archive_runtime_files_nothing_to_archive( assert not (archive_dir / "other.txt").exists() assert not (archive_dir / "script+0006.qqinfo").exists() assert not (archive_dir / "script+0004.qqout").exists() + + +def test_create_init_file_creates_file_for_given_cycle(tmp_path: Path): + archive_format = str(tmp_path / "job%04d") + archiver = Archiver( + archive=tmp_path, + archive_format=archive_format, + input_machine="localhost", + input_dir=tmp_path, + batch_system=PBS, + ) + + archiver.create_init_file(cycle=1) + + assert (tmp_path / "job0001.init").exists() + + +def test_create_init_file_creates_empty_file(tmp_path: Path): + archive_format = str(tmp_path / "job%04d") + archiver = Archiver( + archive=tmp_path, + archive_format=archive_format, + input_machine="localhost", + input_dir=tmp_path, + batch_system=PBS, + ) + + archiver.create_init_file(cycle=1) + + assert (tmp_path / "job0001.init").stat().st_size == 0 + + +def test_create_init_file_uses_correct_cycle_number(tmp_path: Path): + archive_format = str(tmp_path / "md%03d") + archiver = Archiver( + archive=tmp_path, + archive_format=archive_format, + input_machine="localhost", + input_dir=tmp_path, + batch_system=PBS, + ) + + archiver.create_init_file(cycle=42) + + assert (tmp_path / "md042.init").exists() diff --git a/tests/run/test_run_runner.py b/tests/run/test_run_runner.py index 3543c07..27e9de7 100644 --- a/tests/run/test_run_runner.py +++ b/tests/run/test_run_runner.py @@ -10,6 +10,7 @@ import pytest +from qq_lib.archive import Archiver from qq_lib.core.error import ( QQError, QQJobMismatchError, @@ -18,6 +19,7 @@ ) from qq_lib.properties.interpreter import Interpreter from qq_lib.properties.job_type import JobType +from qq_lib.properties.loop import LoopInfo from qq_lib.properties.states import NaiveState from qq_lib.run.runner import CFG, Runner, log_fatal_error_and_exit @@ -911,6 +913,7 @@ def test_runner_finalize_with_scratch_and_archiver(mock_logger_info): runner._delete_work_dir = MagicMock() runner._update_info_finished = MagicMock() + runner._archive_files_from_work_dir = MagicMock() with ( patch("qq_lib.run.runner.Retryer") as retryer_mock, @@ -921,7 +924,7 @@ def test_runner_finalize_with_scratch_and_archiver(mock_logger_info): ): runner.finalize() - runner._archiver.to_archive.assert_called_once_with(runner._work_dir) + runner._archive_files_from_work_dir.assert_called_once() retryer_mock.assert_called_once() runner._delete_work_dir.assert_called_once() runner._update_info_finished.assert_called_once() @@ -948,10 +951,11 @@ def test_runner_finalize_with_scratch_and_archiver_at_failure(mock_logger_info): runner._delete_work_dir = MagicMock() runner._update_info_failed = MagicMock() + runner._archive_files_from_work_dir = MagicMock() runner.finalize() - runner._archiver.to_archive.assert_not_called() + runner._archive_files_from_work_dir.assert_not_called() runner._delete_work_dir.assert_not_called() runner._update_info_failed.assert_called_once() @@ -976,6 +980,7 @@ def test_runner_finalize_with_scratch_and_without_archiver(mock_logger_info): runner._delete_work_dir = MagicMock() runner._update_info_finished = MagicMock() + runner._archive_files_from_work_dir = MagicMock() with ( patch("qq_lib.run.runner.Retryer") as retryer_mock, @@ -986,6 +991,7 @@ def test_runner_finalize_with_scratch_and_without_archiver(mock_logger_info): retryer_mock.assert_called_once() runner._delete_work_dir.assert_called_once() runner._update_info_finished.assert_called_once() + runner._archive_files_from_work_dir.assert_not_called() mock_logger_info.assert_any_call("Finalizing the execution.") mock_logger_info.assert_any_call("Job completed with an exit code of 0.") @@ -1007,10 +1013,11 @@ def test_runner_finalize_without_scratch_and_with_archiver(mock_logger_info): runner._delete_work_dir = MagicMock() runner._update_info_finished = MagicMock() + runner._archive_files_from_work_dir = MagicMock() runner.finalize() - runner._archiver.to_archive.assert_called_once_with(runner._work_dir) + runner._archive_files_from_work_dir.assert_called_once() runner._delete_work_dir.assert_not_called() runner._update_info_finished.assert_called_once() mock_logger_info.assert_any_call("Finalizing the execution.") @@ -1034,10 +1041,11 @@ def test_runner_finalize_without_scratch_and_with_archiver_at_failure(mock_logge runner._delete_work_dir = MagicMock() runner._update_info_failed = MagicMock() + runner._archive_files_from_work_dir = MagicMock() runner.finalize() - runner._archiver.to_archive.assert_not_called() + runner._archive_files_from_work_dir.assert_not_called() runner._delete_work_dir.assert_not_called() runner._update_info_failed.assert_called_once() mock_logger_info.assert_any_call("Finalizing the execution.") @@ -1060,11 +1068,13 @@ def test_runner_finalize_without_scratch_and_without_archiver(mock_logger_info): runner._delete_work_dir = MagicMock() runner._update_info_finished = MagicMock() + runner._archive_files_from_work_dir = MagicMock() runner.finalize() runner._delete_work_dir.assert_not_called() runner._update_info_finished.assert_called_once() + runner._archive_files_from_work_dir.assert_not_called() mock_logger_info.assert_any_call("Finalizing the execution.") mock_logger_info.assert_any_call("Job completed with an exit code of 0.") @@ -1086,6 +1096,7 @@ def test_runner_finalize_with_scratch_archiver_and_resubmit(mock_logger_info): runner._delete_work_dir = MagicMock() runner._update_info_finished = MagicMock() runner._resubmit = MagicMock() + runner._archive_files_from_work_dir = MagicMock() resubmitter_mock = MagicMock() resubmitter_mock.resubmit.return_value = "12345" @@ -1096,7 +1107,7 @@ def test_runner_finalize_with_scratch_archiver_and_resubmit(mock_logger_info): ): runner.finalize() - runner._archiver.to_archive.assert_called_once_with(runner._work_dir) + runner._archive_files_from_work_dir.assert_called_once() retryer_mock.assert_called_once() runner._delete_work_dir.assert_called_once() runner._resubmit.assert_called_once() @@ -1664,3 +1675,102 @@ def test_runner_copy_files_calls_sync_selected(tmp_path): ) assert runner._batch_system.sync_selected.call_count == 2 + + +def _make_runner_with_archiver( + tmp_path: Path, + loop_info: LoopInfo, +) -> tuple[Runner, MagicMock]: + runner = Runner.__new__(Runner) + archiver = MagicMock(spec=Archiver) + runner._archiver = archiver + runner._work_dir = tmp_path + + informer = MagicMock() + informer.info.loop_info = loop_info + runner._informer = informer + + return runner, archiver + + +def test_archive_files_from_work_dir_calls_to_archive(tmp_path: Path): + loop_info = LoopInfo( + start=1, + end=10, + archive=tmp_path / "archive", + archive_format="job%04d", + current=1, + ) + runner, archiver = _make_runner_with_archiver(tmp_path, loop_info) + archiver.get_files_matching_pattern.return_value = ["job0002"] + + runner._archive_files_from_work_dir() + + archiver.to_archive.assert_called_once_with(tmp_path) + + +def test_archive_files_from_work_dir_creates_init_file_when_no_matching_files( + tmp_path: Path, +): + loop_info = LoopInfo( + start=1, + end=10, + archive=tmp_path / "archive", + archive_format="job%04d", + current=3, + ) + runner, archiver = _make_runner_with_archiver(tmp_path, loop_info) + archiver.get_files_matching_pattern.return_value = [] + + runner._archive_files_from_work_dir() + + archiver.create_init_file.assert_called_once_with(4) + + +def test_archive_files_from_work_dir_does_not_create_init_file_when_matching_files_exist( + tmp_path: Path, +): + loop_info = LoopInfo( + start=1, + end=10, + archive=tmp_path / "archive", + archive_format="job%04d", + current=1, + ) + runner, archiver = _make_runner_with_archiver(tmp_path, loop_info) + archiver.get_files_matching_pattern.return_value = ["job0002"] + + runner._archive_files_from_work_dir() + + archiver.create_init_file.assert_not_called() + + +def test_archive_files_from_work_dir_raises_when_archiver_is_none(tmp_path: Path): + runner = Runner.__new__(Runner) + runner._archiver = None + loop_info = LoopInfo( + start=1, + end=10, + archive=tmp_path / "archive", + archive_format="job%04d", + current=1, + ) + informer = MagicMock() + informer.info.loop_info = loop_info + runner._informer = informer + runner._work_dir = tmp_path + + with pytest.raises(QQError, match="Archiver is undefined"): + runner._archive_files_from_work_dir() + + +def test_archive_files_from_work_dir_raises_when_loop_info_is_none(tmp_path: Path): + runner = Runner.__new__(Runner) + runner._archiver = MagicMock(spec=Archiver) + runner._work_dir = tmp_path + informer = MagicMock() + informer.info.loop_info = None + runner._informer = informer + + with pytest.raises(QQError, match="Loop info is undefined"): + runner._archive_files_from_work_dir() From 6626cd3b8778ffe6a3bd389d7b046208b5cbbc6e Mon Sep 17 00:00:00 2001 From: Ladme Date: Sat, 16 May 2026 16:35:58 +0200 Subject: [PATCH 02/20] Removed unnecessary construction of Informer in Submitter.submit --- src/qq_lib/submit/submitter.py | 57 ++++++++++---------- tests/submit/test_submit_submitter.py | 77 ++++++++++++--------------- 2 files changed, 62 insertions(+), 72 deletions(-) diff --git a/src/qq_lib/submit/submitter.py b/src/qq_lib/submit/submitter.py index ee04af9..13d79dd 100644 --- a/src/qq_lib/submit/submitter.py +++ b/src/qq_lib/submit/submitter.py @@ -161,40 +161,37 @@ def submit(self, remote: str | None = None) -> str: ) # create job qq info file - informer = Informer( - Info( - batch_system=self._batch_system, - qq_version=qq_lib.__version__, - username=getpass.getuser(), - job_id=job_id, - job_name=self._job_name, - script_name=self._script_name, - queue=self._queue, - job_type=self._job_type, - input_machine=socket.getfqdn(remote or ""), - input_dir=self._input_dir, - job_state=NaiveState.QUEUED, - submission_time=datetime.now(), - stdout_file=str(Path(self._job_name).with_suffix(CFG.suffixes.stdout)), - stderr_file=str(Path(self._job_name).with_suffix(CFG.suffixes.stderr)), - resources=self._resources, - loop_info=self._loop_info, - excluded_files=self._exclude, - included_files=self._include, - depend=self._depend, - account=self._account, - transfer_mode=self._transfer_mode, - server=self._server, - interpreter=self._interpreter, - resubmit_from=self._resubmit_from, - ) - ) - # we create the info file from the current machine no matter # whether we are submiting from the current machine or from the remote machine # the input directory should be available on both concerned machines, # so this should be okay - informer.to_file(self._info_file) + Info( + batch_system=self._batch_system, + qq_version=qq_lib.__version__, + username=getpass.getuser(), + job_id=job_id, + job_name=self._job_name, + script_name=self._script_name, + queue=self._queue, + job_type=self._job_type, + input_machine=socket.getfqdn(remote or ""), + input_dir=self._input_dir, + job_state=NaiveState.QUEUED, + submission_time=datetime.now(), + stdout_file=str(Path(self._job_name).with_suffix(CFG.suffixes.stdout)), + stderr_file=str(Path(self._job_name).with_suffix(CFG.suffixes.stderr)), + resources=self._resources, + loop_info=self._loop_info, + excluded_files=self._exclude, + included_files=self._include, + depend=self._depend, + account=self._account, + transfer_mode=self._transfer_mode, + server=self._server, + interpreter=self._interpreter, + resubmit_from=self._resubmit_from, + ).to_file(self._info_file) + return job_id def continues_loop(self) -> bool: diff --git a/tests/submit/test_submit_submitter.py b/tests/submit/test_submit_submitter.py index 7248873..6e86529 100644 --- a/tests/submit/test_submit_submitter.py +++ b/tests/submit/test_submit_submitter.py @@ -613,11 +613,11 @@ def test_submitter_submit_calls_all_steps_and_returns_job_id(tmp_path): patch.object( submitter._batch_system, "job_submit", return_value="jobid123" ) as mock_job_submit, - patch("qq_lib.submit.submitter.Informer") as mock_informer_class, + patch("qq_lib.submit.submitter.Info") as mock_info_class, patch("qq_lib.__version__", "1.0"), ): - mock_informer_instance = MagicMock() - mock_informer_class.return_value = mock_informer_instance + mock_info_instance = MagicMock() + mock_info_class.return_value = mock_info_instance result = submitter.submit() @@ -633,8 +633,8 @@ def test_submitter_submit_calls_all_steps_and_returns_job_id(tmp_path): submitter._server, remote_host=None, ) - mock_informer_class.assert_called_once() - mock_informer_instance.to_file.assert_called_once_with(submitter._info_file) + mock_info_class.assert_called_once() + mock_info_instance.to_file.assert_called_once_with(submitter._info_file) assert result == "jobid123" @@ -667,15 +667,15 @@ def test_submitter_submit(tmp_path): patch.object( submitter._batch_system, "job_submit", return_value="jobid123" ) as mock_job_submit, - patch("qq_lib.submit.submitter.Informer") as mock_informer_class, + patch("qq_lib.submit.submitter.Info") as mock_info_class, patch("qq_lib.__version__", "1.0"), patch("getpass.getuser", return_value="testuser"), patch("socket.getfqdn", return_value="host123"), patch("qq_lib.submit.submitter.datetime") as mock_datetime, ): mock_datetime.now.return_value = datetime(2025, 10, 14, 12, 0, 0) - mock_informer_instance = MagicMock() - mock_informer_class.return_value = mock_informer_instance + mock_info_instance = MagicMock() + mock_info_class.return_value = mock_info_instance result = submitter.submit() @@ -691,38 +691,31 @@ def test_submitter_submit(tmp_path): submitter._server, remote_host=None, ) - mock_informer_class.assert_called_once() - mock_informer_instance.to_file.assert_called_once_with(submitter._info_file) - assert result == "jobid123" - - # capture the Info passed to Informer - info_arg = mock_informer_class.call_args[0][0] - - assert info_arg.batch_system == submitter._batch_system - assert info_arg.qq_version == "1.0" - assert info_arg.username == "testuser" - assert info_arg.job_id == "jobid123" - assert info_arg.job_name == submitter._job_name - assert info_arg.script_name == submitter._script_name - assert info_arg.queue == submitter._queue - assert info_arg.account == submitter._account - assert info_arg.job_type == submitter._job_type - assert info_arg.input_machine == "host123" - assert info_arg.input_dir == submitter._input_dir - assert info_arg.job_state == NaiveState.QUEUED - assert info_arg.submission_time == datetime(2025, 10, 14, 12, 0, 0) - assert info_arg.stdout_file == str( - Path(submitter._job_name).with_suffix(CFG.suffixes.stdout) + mock_info_class.assert_called_once_with( + batch_system=submitter._batch_system, + qq_version="1.0", + username="testuser", + job_id="jobid123", + job_name=submitter._job_name, + script_name=submitter._script_name, + queue=submitter._queue, + account=submitter._account, + job_type=submitter._job_type, + input_machine="host123", + input_dir=submitter._input_dir, + job_state=NaiveState.QUEUED, + submission_time=datetime(2025, 10, 14, 12, 0, 0), + stdout_file=str(Path(submitter._job_name).with_suffix(CFG.suffixes.stdout)), + stderr_file=str(Path(submitter._job_name).with_suffix(CFG.suffixes.stderr)), + resources=submitter._resources, + loop_info=submitter._loop_info, + excluded_files=submitter._exclude, + included_files=submitter._include, + depend=submitter._depend, + transfer_mode=[Always()], + server=submitter._server, + interpreter=None, + resubmit_from=[WorkHost()], ) - assert info_arg.stderr_file == str( - Path(submitter._job_name).with_suffix(CFG.suffixes.stderr) - ) - assert info_arg.resources == submitter._resources - assert info_arg.loop_info == submitter._loop_info - assert info_arg.excluded_files == submitter._exclude - assert info_arg.included_files == submitter._include - assert info_arg.depend == submitter._depend - assert info_arg.transfer_mode == [Always()] - assert info_arg.server == submitter._server - assert info_arg.interpreter is None - assert info_arg.resubmit_from == [WorkHost()] + mock_info_instance.to_file.assert_called_once_with(submitter._info_file) + assert result == "jobid123" From fd3bae157e9d70c90647d42929f10023d487d89d Mon Sep 17 00:00:00 2001 From: Ladme Date: Sun, 17 May 2026 18:41:44 +0200 Subject: [PATCH 03/20] Obtaining multiple batch jobs in a single PBS command --- src/qq_lib/batch/interface/interface.py | 23 +++ src/qq_lib/batch/pbs/pbs.py | 31 +++- src/qq_lib/info/informer.py | 6 + tests/batch/pbs/test_pbs.py | 183 +++++++++++++++++++++++- 4 files changed, 232 insertions(+), 11 deletions(-) diff --git a/src/qq_lib/batch/interface/interface.py b/src/qq_lib/batch/interface/interface.py index a0b81d5..be4f9b8 100644 --- a/src/qq_lib/batch/interface/interface.py +++ b/src/qq_lib/batch/interface/interface.py @@ -296,6 +296,29 @@ def get_batch_job(cls, job_id: str) -> TBatchJob: f"get_batch_job method is not implemented for {cls.__name__}" ) + @classmethod + def get_batch_jobs_from_ids(cls, job_ids: list[str]) -> list[TBatchJob]: + """ + Retrieve information about multiple jobs from the batch system. + + Batch jobs should be returned in the same order as they appear in `job_ids`. + A TBatchJob object should be returned for each job id, even if the job + no longer exists or its information is unavailable. + + Array jobs should NOT be expanded into their individual tasks. + + The default implementation is to call `get_batch_job` for each job id. + This implementation may be inefficient for large numbers of job ids and + should be overriden by subclasses. + + Args: + job_ids (list[str]): List of job identifiers. + + Returns: + list[TBatchJob]: List of TBatchJob objects, one for each job id. + """ + return [cls.get_batch_job(id) for id in job_ids] + @classmethod def get_unfinished_batch_jobs( cls, user: str, server: str | None = None diff --git a/src/qq_lib/batch/pbs/pbs.py b/src/qq_lib/batch/pbs/pbs.py index 36179f6..b9ed446 100644 --- a/src/qq_lib/batch/pbs/pbs.py +++ b/src/qq_lib/batch/pbs/pbs.py @@ -183,6 +183,14 @@ def job_kill_force(cls, job_id: str) -> None: def get_batch_job(cls, job_id: str) -> PBSJob: return PBSJob(job_id) + @classmethod + def get_batch_jobs_from_ids(cls, job_ids: list[str]) -> list[PBSJob]: + command = f"qstat -fxw {' '.join(job_ids)}" + logger.debug(command) + return cls._get_batch_jobs_using_command( + command, include_completed=True, include_top_level_array=True + ) + @classmethod def get_unfinished_batch_jobs( cls, user: str, server: str | None = None @@ -191,7 +199,9 @@ def get_unfinished_batch_jobs( if server: command += f" @{server}" logger.debug(command) - return cls._get_batch_jobs_using_command(command, include_completed=False) + return cls._get_batch_jobs_using_command( + command, include_completed=False, include_top_level_array=True + ) @classmethod def get_batch_jobs(cls, user: str, server: str | None = None) -> list[PBSJob]: @@ -199,7 +209,9 @@ def get_batch_jobs(cls, user: str, server: str | None = None) -> list[PBSJob]: if server: command += f" @{server}" logger.debug(command) - return cls._get_batch_jobs_using_command(command, include_completed=True) + return cls._get_batch_jobs_using_command( + command, include_completed=True, include_top_level_array=True + ) @classmethod def get_all_unfinished_batch_jobs(cls, server: str | None = None) -> list[PBSJob]: @@ -207,7 +219,9 @@ def get_all_unfinished_batch_jobs(cls, server: str | None = None) -> list[PBSJob if server: command += f" @{server}" logger.debug(command) - return cls._get_batch_jobs_using_command(command, include_completed=False) + return cls._get_batch_jobs_using_command( + command, include_completed=False, include_top_level_array=True + ) @classmethod def get_all_batch_jobs(cls, server: str | None = None) -> list[PBSJob]: @@ -215,7 +229,9 @@ def get_all_batch_jobs(cls, server: str | None = None) -> list[PBSJob]: if server: command += f" @{server}" logger.debug(command) - return cls._get_batch_jobs_using_command(command, include_completed=True) + return cls._get_batch_jobs_using_command( + command, include_completed=True, include_top_level_array=True + ) @classmethod def get_queues(cls, server: str | None = None) -> list[PBSQueue]: @@ -1000,7 +1016,7 @@ def _sync_directories( @classmethod def _get_batch_jobs_using_command( - cls, command: str, include_completed: bool + cls, command: str, include_completed: bool, include_top_level_array: bool ) -> list[PBSJob]: """ Execute a shell command to retrieve information about PBS jobs and parse it. @@ -1009,6 +1025,8 @@ def _get_batch_jobs_using_command( command (str): The shell command to execute, typically a PBS query command. include_completed (bool): Include both completed and uncompleted jobs. If `False`, completed jobs are filtered out from the output. + include_top_level_array (bool): Include top-level array jobs in the output. + If `False`, top-level array jobs are filtered out from the output. Returns: list[PBSJob]: A list of `PBSJob` instances corresponding to the jobs @@ -1039,8 +1057,7 @@ def _get_batch_jobs_using_command( ): job = PBSJob.from_dict(job_id, data) - # ignore top-level array jobs - if job.is_array_job(): + if not include_top_level_array and job.is_array_job(): continue # unless all jobs are to be shown, filter out completed jobs diff --git a/src/qq_lib/info/informer.py b/src/qq_lib/info/informer.py index 0c90f7f..988030f 100644 --- a/src/qq_lib/info/informer.py +++ b/src/qq_lib/info/informer.py @@ -226,6 +226,12 @@ def load_batch_info(self) -> None: if self._batch_info is None: self._batch_info = self.batch_system.get_batch_job(self.info.job_id) + def set_batch_info(self, batch_info: BatchJobInterface) -> None: + """ + Set the batch job information. + """ + self._batch_info = batch_info + def get_batch_state(self) -> BatchState: """ Return the job's state as reported by the batch system. diff --git a/tests/batch/pbs/test_pbs.py b/tests/batch/pbs/test_pbs.py index a1a9e32..cdb507f 100644 --- a/tests/batch/pbs/test_pbs.py +++ b/tests/batch/pbs/test_pbs.py @@ -1337,13 +1337,13 @@ def sample_multi_dump_file(): """ -def test_get_jobs_info_using_command_success(sample_multi_dump_file): +def test_get_batch_jobs_using_command_success(sample_multi_dump_file): with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock( returncode=0, stdout=sample_multi_dump_file, stderr="" ) - jobs = PBS._get_batch_jobs_using_command("fake command - unused", False) + jobs = PBS._get_batch_jobs_using_command("fake command - unused", False, True) assert len(jobs) == 3 assert all(isinstance(job, PBSJob) for job in jobs) @@ -1376,7 +1376,182 @@ def test_get_jobs_info_using_command_success(sample_multi_dump_file): ) -def test_get_jobs_info_using_command_nonzero_returncode(): +@pytest.fixture +def sample_multi_dump_file_with_completed_job(): + return """Job Id: 123456.fake-cluster.example.com + Job_Name = example_job_1 + Job_Owner = user@EXAMPLE + resources_used.cpupercent = 50 + resources_used.ncpus = 4 + job_state = R + queue = gpu + +Job Id: 123457.fake-cluster.example.com + Job_Name = example_job_2 + Job_Owner = user@EXAMPLE + resources_used.cpupercent = 75 + resources_used.ncpus = 8 + job_state = F + Exit_status = 0 + queue = cpu + +Job Id: 123458.fake-cluster.example.com + Job_Name = example_job_3 + Job_Owner = user@EXAMPLE + resources_used.cpupercent = 100 + resources_used.ncpus = 16 + job_state = H + queue = gpu +""" + + +def test_get_batch_jobs_using_command_filter_out_completed_jobs( + sample_multi_dump_file_with_completed_job, +): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=0, stdout=sample_multi_dump_file_with_completed_job, stderr="" + ) + + jobs = PBS._get_batch_jobs_using_command("fake command - unused", False, True) + + assert len(jobs) == 2 + assert all(isinstance(job, PBSJob) for job in jobs) + + expected_ids = [ + "123456.fake-cluster.example.com", + "123458.fake-cluster.example.com", + ] + assert [job._job_id for job in jobs] == expected_ids + + assert [job._info["Job_Name"] for job in jobs] == [ + "example_job_1", + "example_job_3", + ] + assert [job._info["job_state"] for job in jobs] == [ + "R", + "H", + ] + + mock_run.assert_called_once_with( + ["bash"], + input="fake command - unused", + text=True, + check=False, + capture_output=True, + errors="replace", + ) + + +@pytest.fixture +def sample_multi_dump_file_with_array_job(): + return """Job Id: 123456.fake-cluster.example.com + Job_Name = example_job_1 + Job_Owner = user@EXAMPLE + resources_used.cpupercent = 50 + resources_used.ncpus = 4 + job_state = R + queue = gpu + +Job Id: 123457[].fake-cluster.example.com + Job_Name = example_job_2 + Job_Owner = user@EXAMPLE + resources_used.cpupercent = 75 + resources_used.ncpus = 8 + job_state = Q + array = True + queue = cpu + +Job Id: 123458.fake-cluster.example.com + Job_Name = example_job_3 + Job_Owner = user@EXAMPLE + resources_used.cpupercent = 100 + resources_used.ncpus = 16 + job_state = H + queue = gpu +""" + + +def test_get_batch_jobs_using_command_filter_out_top_level_array_jobs( + sample_multi_dump_file_with_array_job, +): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=0, stdout=sample_multi_dump_file_with_array_job, stderr="" + ) + + jobs = PBS._get_batch_jobs_using_command("fake command - unused", False, False) + + assert len(jobs) == 2 + assert all(isinstance(job, PBSJob) for job in jobs) + + expected_ids = [ + "123456.fake-cluster.example.com", + "123458.fake-cluster.example.com", + ] + assert [job._job_id for job in jobs] == expected_ids + + assert [job._info["Job_Name"] for job in jobs] == [ + "example_job_1", + "example_job_3", + ] + assert [job._info["job_state"] for job in jobs] == [ + "R", + "H", + ] + + mock_run.assert_called_once_with( + ["bash"], + input="fake command - unused", + text=True, + check=False, + capture_output=True, + errors="replace", + ) + + +def test_get_batch_jobs_using_command_keep_top_level_array_jobs( + sample_multi_dump_file_with_array_job, +): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=0, stdout=sample_multi_dump_file_with_array_job, stderr="" + ) + + jobs = PBS._get_batch_jobs_using_command("fake command - unused", False, True) + + assert len(jobs) == 3 + assert all(isinstance(job, PBSJob) for job in jobs) + + expected_ids = [ + "123456.fake-cluster.example.com", + "123457[].fake-cluster.example.com", + "123458.fake-cluster.example.com", + ] + assert [job._job_id for job in jobs] == expected_ids + + assert [job._info["Job_Name"] for job in jobs] == [ + "example_job_1", + "example_job_2", + "example_job_3", + ] + assert [job._info["job_state"] for job in jobs] == [ + "R", + "Q", + "H", + ] + + mock_run.assert_called_once_with( + ["bash"], + input="fake command - unused", + text=True, + check=False, + capture_output=True, + errors="replace", + ) + + +def test_get_batch_jobs_using_command_nonzero_returncode(): with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock( returncode=1, stdout="", stderr="Some error occurred" @@ -1385,7 +1560,7 @@ def test_get_jobs_info_using_command_nonzero_returncode(): QQError, match="Could not retrieve information about jobs: Some error occurred", ): - PBS._get_batch_jobs_using_command("will not be used", True) + PBS._get_batch_jobs_using_command("will not be used", True, True) @pytest.mark.parametrize( From 783949d3b46b49a854bbc1a879b5874934c7ec37 Mon Sep 17 00:00:00 2001 From: Ladme Date: Thu, 21 May 2026 21:09:41 +0200 Subject: [PATCH 04/20] Printing warnings when using options that are ignored --- CHANGELOG.md | 1 + src/qq_lib/submit/factory.py | 41 ++++++ tests/submit/test_submit_factory.py | 199 ++++++++++++++++++++++++++++ 3 files changed, 241 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6f540a..998a87f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## Version 0.12.0 - If a loop job does not create any archive files for the next cycle of the loop job, qq creates an empty `.init` file fulfilling the loop job's requirements. +- If a loop job-specific or continuous job-specific option is used either on the command line or inside the submitted script, a warning is printed so that the user knows that the option will be ignored. *** diff --git a/src/qq_lib/submit/factory.py b/src/qq_lib/submit/factory.py index 6181d3f..6902836 100644 --- a/src/qq_lib/submit/factory.py +++ b/src/qq_lib/submit/factory.py @@ -8,6 +8,7 @@ from qq_lib.core.common import split_files_list, translate_server from qq_lib.core.config import CFG from qq_lib.core.error import QQError +from qq_lib.core.logger import get_logger from qq_lib.properties.depend import Depend from qq_lib.properties.interpreter import Interpreter from qq_lib.properties.job_type import JobType @@ -19,6 +20,8 @@ from .parser import Parser from .submitter import Submitter +logger = get_logger(__name__) + class SubmitterFactory: """ @@ -59,8 +62,16 @@ def make_submitter(self) -> Submitter: if (job_type := self._get_job_type()) == JobType.LOOP: loop_info = self._get_loop_info() else: + # tell the user that any loop job-specific options will be ignored + # because the job is not a loop job + self._print_warning_if_loop_info_defined(job_type) loop_info = None + if job_type not in (JobType.LOOP, JobType.CONTINUOUS): + # tell the user that 'resubmit_from' will be ignored if the + # job type is not 'loop' or 'continuous' + self._print_warning_if_resubmit_from_defined(job_type) + server = self._get_server() return Submitter( @@ -192,6 +203,36 @@ def _get_loop_info(self) -> LoopInfo: or self._parser.get_archive_mode(), ) + def _print_warning_if_loop_info_defined(self, job_type: JobType) -> None: + """ + Print warning(s) if any of the loop-job specific options + are defined either on the command line or in the script itself. + This should only be used if the job is not a loop job. + """ + for option, parser_func in zip( + ["loop_start", "loop_end", "archive", "archive_format", "archive_mode"], + [ + self._parser.get_loop_start, + self._parser.get_loop_end, + self._parser.get_archive, + self._parser.get_archive_format, + self._parser.get_archive_mode, + ], + ): + if self._kwargs.get(option) or parser_func(): + logger.warning( + f"Option '{option}' is specified but job type is '{str(job_type)}', not 'loop' - '{option}' will be ignored." + ) + + def _print_warning_if_resubmit_from_defined(self, job_type: JobType) -> None: + """ + Print a warning if the 'resubmit_from' option is defined but the job type is not 'loop' or 'continuous'. + """ + if self._kwargs.get("resubmit_from") or self._parser.get_resubmit_from(): + logger.warning( + f"Option 'resubmit_from' is specified but job type is '{str(job_type)}', not 'loop' or 'continuous' - 'resubmit_from' will be ignored." + ) + def _get_exclude(self) -> list[Path]: """ Determine the files to exclude from being copied to the job's working directory. diff --git a/tests/submit/test_submit_factory.py b/tests/submit/test_submit_factory.py index 4fa6cf2..2031783 100644 --- a/tests/submit/test_submit_factory.py +++ b/tests/submit/test_submit_factory.py @@ -27,6 +27,7 @@ Success, TransferMode, ) +from qq_lib.submit import Parser from qq_lib.submit.factory import SubmitterFactory @@ -756,6 +757,12 @@ def test_submitter_factory_make_submitter_standard_job(server): factory, "_get_resubmit_from", return_value=resubmit_from ) as mock_resubmit_from, patch("qq_lib.submit.factory.Submitter") as mock_submitter_class, + patch.object( + factory, "_print_warning_if_loop_info_defined" + ) as mock_print_warning_loop, + patch.object( + factory, "_print_warning_if_resubmit_from_defined" + ) as mock_print_warning_resubmit, ): mock_submit_instance = MagicMock() mock_submitter_class.return_value = mock_submit_instance @@ -775,6 +782,8 @@ def test_submitter_factory_make_submitter_standard_job(server): mock_get_transfer.assert_called_once() mock_get_interpreter.assert_called_once() mock_resubmit_from.assert_not_called() + mock_print_warning_loop.assert_called_once() + mock_print_warning_resubmit.assert_called_once() mock_submitter_class.assert_called_once_with( batch_system=BatchSystem, @@ -842,6 +851,12 @@ def test_submitter_factory_make_submitter_loop_job(server): ) as mock_resubmit_from, patch.object(factory, "_get_server", return_value=server) as mock_get_server, patch("qq_lib.submit.factory.Submitter") as mock_submitter_class, + patch.object( + factory, "_print_warning_if_loop_info_defined" + ) as mock_print_warning_loop, + patch.object( + factory, "_print_warning_if_resubmit_from_defined" + ) as mock_print_warning_resubmit, ): mock_submit_instance = MagicMock() mock_submitter_class.return_value = mock_submit_instance @@ -861,6 +876,8 @@ def test_submitter_factory_make_submitter_loop_job(server): mock_get_transfer.assert_called_once() mock_get_interpreter.assert_called_once() mock_resubmit_from.assert_called_once() + mock_print_warning_loop.assert_not_called() + mock_print_warning_resubmit.assert_not_called() mock_submitter_class.assert_called_once_with( batch_system=BatchSystem, @@ -879,3 +896,185 @@ def test_submitter_factory_make_submitter_loop_job(server): resubmit_from=resubmit_from, ) assert result == mock_submit_instance + + +@pytest.mark.parametrize("server", [None, "fake.server.org"]) +def test_submitter_factory_make_submitter_continuous_job(server): + mock_parser = MagicMock() + mock_parser.parse = MagicMock() + mock_parser.get_job_type.return_value = JobType.CONTINUOUS + resources = Resources() + excludes = [Path("/tmp/file1")] + includes = [Path("included_file")] + depends = [] + account = None + transfer = [Always()] + interpreter = "python3" + resubmit_from = [WorkHost(), InputHost()] + + factory = SubmitterFactory.__new__(SubmitterFactory) + factory._parser = mock_parser + factory._script = Path("/tmp/script.sh") + factory._kwargs = {"queue": "default", "job_type": "continuous"} + + BatchSystem = MagicMock() + queue = "default" + loop_info = MagicMock() + + with ( + patch.object( + factory, "_get_batch_system", return_value=BatchSystem + ) as mock_get_batch, + patch.object(factory, "_get_queue", return_value=queue) as mock_get_queue, + patch.object( + factory, "_get_loop_info", return_value=loop_info + ) as mock_get_loop, + patch.object(factory, "_get_resources", return_value=resources) as mock_get_res, + patch.object(factory, "_get_exclude", return_value=excludes) as mock_get_excl, + patch.object(factory, "_get_include", return_value=includes) as mock_get_incl, + patch.object(factory, "_get_depend", return_value=depends) as mock_get_dep, + patch.object(factory, "_get_account", return_value=account) as mock_get_acct, + patch.object( + factory, "_get_interpreter", return_value=interpreter + ) as mock_get_interpreter, + patch.object( + factory, "_get_transfer_mode", return_value=transfer + ) as mock_get_transfer, + patch.object( + factory, "_get_resubmit_from", return_value=resubmit_from + ) as mock_resubmit_from, + patch.object(factory, "_get_server", return_value=server) as mock_get_server, + patch("qq_lib.submit.factory.Submitter") as mock_submitter_class, + patch.object( + factory, "_print_warning_if_loop_info_defined" + ) as mock_print_warning_loop, + patch.object( + factory, "_print_warning_if_resubmit_from_defined" + ) as mock_print_warning_resubmit, + ): + mock_submit_instance = MagicMock() + mock_submitter_class.return_value = mock_submit_instance + + result = factory.make_submitter() + + mock_parser.parse.assert_called_once() + mock_get_batch.assert_called_once() + mock_get_server.assert_called_once() + mock_get_queue.assert_called_once() + mock_get_loop.assert_not_called() + mock_get_res.assert_called_once_with(BatchSystem, queue, server) + mock_get_excl.assert_called_once() + mock_get_incl.assert_called_once() + mock_get_dep.assert_called_once() + mock_get_acct.assert_called_once() + mock_get_transfer.assert_called_once() + mock_get_interpreter.assert_called_once() + mock_resubmit_from.assert_called_once() + mock_print_warning_loop.assert_called_once() + mock_print_warning_resubmit.assert_not_called() + + mock_submitter_class.assert_called_once_with( + batch_system=BatchSystem, + queue=queue, + account=account, + script=factory._script, + job_type=JobType.CONTINUOUS, + resources=resources, + loop_info=None, + exclude=excludes, + include=includes, + depend=depends, + transfer_mode=transfer, + server=server, + interpreter=interpreter, + resubmit_from=resubmit_from, + ) + assert result == mock_submit_instance + + +LOOP_OPTIONS = [ + ("loop_start", 1), + ("loop_end", 100), + ("archive", "archive"), + ("archive_format", "md%04d"), + ("archive_mode", "always"), +] + + +def _make_factory( + kwargs: dict[str, object] | None = None, + parsed: dict[str, object] | None = None, +) -> SubmitterFactory: + parser = pytest.Parser.__new__(Parser) + parser._options = dict(parsed) if parsed else {} + + factory = SubmitterFactory.__new__(SubmitterFactory) + factory._parser = parser + factory._kwargs = dict(kwargs) if kwargs else {} + return factory + + +@pytest.mark.parametrize(("option", "value"), LOOP_OPTIONS) +@patch("qq_lib.submit.factory.logger") +def test_submitter_factory_print_warning_if_loop_info_defined_warns_from_kwargs( + mock_logger, option, value +): + factory = _make_factory(kwargs={option: value}) + + factory._print_warning_if_loop_info_defined(JobType.STANDARD) + + assert any(option in str(call) for call in mock_logger.warning.call_args_list) + + +@pytest.mark.parametrize(("option", "value"), LOOP_OPTIONS) +@patch("qq_lib.submit.factory.logger") +def test_submitter_factory_print_warning_if_loop_info_defined_warns_from_parser( + mock_logger, option, value +): + factory = _make_factory(parsed={option: value}) + + factory._print_warning_if_loop_info_defined(JobType.STANDARD) + + assert any(option in str(call) for call in mock_logger.warning.call_args_list) + + +@patch("qq_lib.submit.factory.logger") +def test_submitter_factory_print_warning_if_loop_info_defined_silent(mock_logger): + factory = _make_factory() + + factory._print_warning_if_loop_info_defined(JobType.STANDARD) + + mock_logger.warning.assert_not_called() + + +@patch("qq_lib.submit.factory.logger") +def test_submitter_factory_print_warning_if_resubmit_from_defined_warns_from_kwargs( + mock_logger, +): + factory = _make_factory(kwargs={"resubmit_from": "host1"}) + + factory._print_warning_if_resubmit_from_defined(JobType.STANDARD) + + mock_logger.warning.assert_called_once() + assert "resubmit_from" in str(mock_logger.warning.call_args) + + +@patch("qq_lib.submit.factory.logger") +def test_submitter_factory_print_warning_if_resubmit_from_defined_warns_from_parser( + mock_logger, +): + factory = _make_factory(parsed={"resubmit_from": "host1"}) + + factory._print_warning_if_resubmit_from_defined(JobType.STANDARD) + + mock_logger.warning.assert_called_once() + assert "resubmit_from" in str(mock_logger.warning.call_args) + + +@patch("qq_lib.submit.factory.logger") +def test_submitter_factory_print_warning_if_resubmit_from_defined_silent(mock_logger): + factory = _make_factory() + + factory._print_warning_if_resubmit_from_defined(JobType.STANDARD) + + mock_logger.warning.assert_not_called() From 5d665084e8245ce539425a9c31408c85378b07aa Mon Sep 17 00:00:00 2001 From: Ladme Date: Sun, 7 Jun 2026 13:07:29 +0200 Subject: [PATCH 05/20] Bulk operators --- CHANGELOG.md | 7 + src/qq_lib/batch/pbs/common.py | 4 +- src/qq_lib/batch/pbs/pbs.py | 48 ++- src/qq_lib/core/click_format.py | 61 ++++ src/qq_lib/core/command_runner.py | 119 +++++-- src/qq_lib/go/cli.py | 40 ++- src/qq_lib/info/cli.py | 60 +++- src/qq_lib/info/informer.py | 30 +- src/qq_lib/kill/cli.py | 48 ++- src/qq_lib/killall/_deprecated.py | 119 +++++++ src/qq_lib/killall/cli.py | 96 +----- src/qq_lib/respawn/cli.py | 23 +- src/qq_lib/sync/cli.py | 41 ++- src/qq_lib/wipe/cli.py | 25 +- tests/batch/pbs/test_pbs.py | 339 +++++++++++++++++++- tests/core/test_click_format.py | 185 ++++++++++- tests/core/test_command_runner.py | 510 ++++++++++++++++++++---------- tests/go/test_go_cli.py | 58 ++++ tests/info/test_info_cli.py | 62 +++- tests/info/test_info_informer.py | 79 +++++ tests/kill/test_kill_cli.py | 68 +++- tests/killall/test_killall_cli.py | 302 ------------------ tests/respawn/test_respawn_cli.py | 29 ++ tests/sync/test_sync_cli.py | 60 ++++ tests/wipe/test_wipe_cli.py | 35 +- 25 files changed, 1794 insertions(+), 654 deletions(-) create mode 100644 src/qq_lib/killall/_deprecated.py delete mode 100644 tests/killall/test_killall_cli.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 998a87f..ac5b5dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,11 @@ ## Version 0.12.0 + +### Job collections +- `qq info`, `qq kill`, `qq sync`, `qq respawn`, `qq wipe`, and `qq go` can now accept one or more directories and operate on jobs in these directories. Job resolution is parallelized and thus much faster than when these commands are called individually. Some of these commands also accept the `--all` flag to operate on all unfinished jobs on the given batch server. +- **BREAKING CHANGE**: The `-s` option of `qq info` now corresponds to the `--server` option, not to the `--short` flag for consistency with other commands. +- **BREAKING CHANGE**: `qq killall` command has been deprecated in favor of `qq kill --all` which has a very similar behavior. + +### Other changes - If a loop job does not create any archive files for the next cycle of the loop job, qq creates an empty `.init` file fulfilling the loop job's requirements. - If a loop job-specific or continuous job-specific option is used either on the command line or inside the submitted script, a warning is printed so that the user knows that the option will be ignored. diff --git a/src/qq_lib/batch/pbs/common.py b/src/qq_lib/batch/pbs/common.py index 2fe410c..bea6036 100644 --- a/src/qq_lib/batch/pbs/common.py +++ b/src/qq_lib/batch/pbs/common.py @@ -55,7 +55,9 @@ def parse_multi_pbs_dump_to_dictionaries( data = [] block, identifier = [], None - pattern = re.compile(rf"^\s*{keyword}:\s*(.*)$") if keyword else None + pattern = pattern = ( + re.compile(rf"^\s*(?:.*?{keyword}[:\s]+)(.*)$") if keyword else None + ) for line in text.splitlines(): # if the line is empty, start a new block diff --git a/src/qq_lib/batch/pbs/pbs.py b/src/qq_lib/batch/pbs/pbs.py index b9ed446..8a81e59 100644 --- a/src/qq_lib/batch/pbs/pbs.py +++ b/src/qq_lib/batch/pbs/pbs.py @@ -185,10 +185,16 @@ def get_batch_job(cls, job_id: str) -> PBSJob: @classmethod def get_batch_jobs_from_ids(cls, job_ids: list[str]) -> list[PBSJob]: + if not job_ids: + return [] + command = f"qstat -fxw {' '.join(job_ids)}" logger.debug(command) return cls._get_batch_jobs_using_command( - command, include_completed=True, include_top_level_array=True + command, + include_completed=True, + include_top_level_array=True, + ignore_exit_code=True, ) @classmethod @@ -200,7 +206,10 @@ def get_unfinished_batch_jobs( command += f" @{server}" logger.debug(command) return cls._get_batch_jobs_using_command( - command, include_completed=False, include_top_level_array=True + command, + include_completed=False, + include_top_level_array=True, + ignore_exit_code=False, ) @classmethod @@ -210,7 +219,10 @@ def get_batch_jobs(cls, user: str, server: str | None = None) -> list[PBSJob]: command += f" @{server}" logger.debug(command) return cls._get_batch_jobs_using_command( - command, include_completed=True, include_top_level_array=True + command, + include_completed=True, + include_top_level_array=True, + ignore_exit_code=False, ) @classmethod @@ -220,7 +232,10 @@ def get_all_unfinished_batch_jobs(cls, server: str | None = None) -> list[PBSJob command += f" @{server}" logger.debug(command) return cls._get_batch_jobs_using_command( - command, include_completed=False, include_top_level_array=True + command, + include_completed=False, + include_top_level_array=True, + ignore_exit_code=False, ) @classmethod @@ -230,7 +245,10 @@ def get_all_batch_jobs(cls, server: str | None = None) -> list[PBSJob]: command += f" @{server}" logger.debug(command) return cls._get_batch_jobs_using_command( - command, include_completed=True, include_top_level_array=True + command, + include_completed=True, + include_top_level_array=True, + ignore_exit_code=False, ) @classmethod @@ -1016,7 +1034,11 @@ def _sync_directories( @classmethod def _get_batch_jobs_using_command( - cls, command: str, include_completed: bool, include_top_level_array: bool + cls, + command: str, + include_completed: bool, + include_top_level_array: bool, + ignore_exit_code: bool, ) -> list[PBSJob]: """ Execute a shell command to retrieve information about PBS jobs and parse it. @@ -1027,6 +1049,8 @@ def _get_batch_jobs_using_command( If `False`, completed jobs are filtered out from the output. include_top_level_array (bool): Include top-level array jobs in the output. If `False`, top-level array jobs are filtered out from the output. + ignore_exit_code (bool): Ignore the exit code of the command. + If `False`, the command must return a zero exit code. Returns: list[PBSJob]: A list of `PBSJob` instances corresponding to the jobs @@ -1038,17 +1062,21 @@ def _get_batch_jobs_using_command( """ ... result = subprocess.run( - ["bash"], + # -oL (line-buffer stdout), -eL (line-buffer stderr) + # necessary for stdout and stderr merging + ["stdbuf", "-oL", "-eL", "bash"], input=command, text=True, check=False, - capture_output=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, errors="replace", ) - if result.returncode != 0: + if not ignore_exit_code and result.returncode != 0: raise QQError( - f"Could not retrieve information about jobs: {result.stderr.strip()}." + # standard error is written to stdout + f"Could not retrieve information about jobs: {result.stdout.strip()}." ) jobs = [] diff --git a/src/qq_lib/core/click_format.py b/src/qq_lib/core/click_format.py index f0a0a69..47bb61b 100644 --- a/src/qq_lib/core/click_format.py +++ b/src/qq_lib/core/click_format.py @@ -10,6 +10,7 @@ """ from collections.abc import Sequence +from pathlib import Path import click from click import Context, HelpFormatter @@ -150,3 +151,63 @@ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: i += 1 args = processed return super().parse_args(ctx, args) + + +class GlobDirectoryMixin: + """ + Mixin that adds glob-expanding multi-value support for -d/--directory. + + Click does not support nargs=-1 on options. This mixin pre-processes the + argument list before Click's parser sees it, greedily consuming all + non-flag tokens following -d/--directory, glob-expanding each one, and + rewriting them as repeated -d pairs that Click's multiple=True + parser handles correctly. + + Must appear before the Click command class in the MRO so that its + parse_args runs first. + """ + + def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: + """ + Rewrite -d/--directory tokens to support multiple values and globs. + + Greedily consumes all non-flag tokens following a -d/--directory flag, + glob-expands each one relative to its own parent directory, and emits + one -d pair per expanded result. + + Args: + ctx (click.Context): The current Click context. + args (list[str]): The raw argument list. + + Returns: + list[str]: The remaining unparsed arguments after delegation to the + parent parser. + """ + rewritten: list[str] = [] + i = 0 + while i < len(args): + if args[i] in ("-d", "--dir"): + i += 1 + while i < len(args) and not args[i].startswith("-"): + pattern = args[i] + p = Path(pattern) + expanded = sorted(p.parent.glob(p.name)) + if expanded: + for path in expanded: + rewritten.extend(["-d", str(path)]) + else: + # no glob match + # pass through as-is and let Click + # report a proper error via exists=True + rewritten.extend(["-d", pattern]) + i += 1 + else: + rewritten.append(args[i]) + i += 1 + return super().parse_args(ctx, rewritten) # ty:ignore[unresolved-attribute] + + +class QQOperatorCommand(GlobDirectoryMixin, GNUHelpColorsCommand): + """GNUHelpColorsCommand with glob-expanding -d/--directory support.""" + + pass diff --git a/src/qq_lib/core/command_runner.py b/src/qq_lib/core/command_runner.py index 3612cd8..4549a1e 100644 --- a/src/qq_lib/core/command_runner.py +++ b/src/qq_lib/core/command_runner.py @@ -2,6 +2,7 @@ # Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab +import getpass import logging import sys import threading @@ -10,7 +11,8 @@ from pathlib import Path from typing import Any, NoReturn, Self -from qq_lib.core.common import get_info_files +from qq_lib.batch.interface import BatchInterface, BatchJobInterface +from qq_lib.core.common import get_info_files, translate_server from qq_lib.core.config import CFG from qq_lib.core.error import QQError from qq_lib.info import Informer @@ -35,37 +37,50 @@ class CommandRunner: def __init__( self, - jobs: tuple[str, ...], + job_ids: tuple[str, ...], + directories: tuple[Path, ...], + all: bool, + server: str | None, callback: Callable, logger: logging.Logger, *args: Any, n_threads: int = 1, - directory: Path | None = None, **kwargs: Any, ): """ Initialize a CommandRunner. Args: - jobs (tuple[str, ...]): Job IDs provided on the command line. If empty, the `directory` - is searched for qq info files. + job_ids (tuple[str, ...]): List of batch job IDs to operate on. + directories (tuple[Path, ...]): List of directories to search for qq jobs in. + If `job_ids` and `directories` are both empty and `all` is `False`, the current directory is searched for jobs. + all (bool): Collect all qq jobs for the given user on the given server. + server (str | None): The batch server to collect qq jobs from. If `None`, the local batch server is used. + The server can be specified as a shortcut. callback (Callable): The operation to perform on each resolved Informer. The informer is passed as the first argument, followed by `*args` and `**kwargs`. logger (logging.Logger): Logger instance used for error and critical messages. Should be the module-level logger of the calling CLI module. *args (Any): Additional positional arguments forwarded to `callback`. n_threads (int): Number of threads for parallel informer resolution. Defaults to 1 (serial). - directory (Path | None): Directory to search for qq info files. If `None`, the current directory is used. **kwargs (Any): Additional keyword arguments forwarded to `callback`. """ - self._n_threads = n_threads - self._directory = directory or Path.cwd() - self._logger = logger - self._jobs = jobs + self._job_ids = job_ids + self._directories = directories + self._all = all + self._user = getpass.getuser() + self._server = translate_server(server) if server else None + if not self._job_ids and not self._directories and not all: + self._directories = [Path.cwd()] + self._callback = callback self._args = args self._kwargs = kwargs + + self._batch_system = BatchInterface.from_env_var_or_guess() + self._logger = logger self._exception_handlers: dict[type[Exception], Callable] = {} + self._n_threads = n_threads self.n_jobs = 0 self.current_iteration = 0 @@ -132,30 +147,80 @@ def _build_targets(self) -> list[Callable[[], Informer]]: Raises: QQError: If no job IDs were given and no info files were found in the current directory. """ + if self._server and not self._all: + self._logger.warning( + "Server is only used with --all. Ignoring server option." + ) + self._server = None + targets: list[Callable[[], Informer]] = [] - def _resolve_and_prepare(resolve: Callable[[], Informer]) -> Informer: - """Resolve an informer and reload its batch info.""" - informer = resolve() - informer.load_batch_info() - return informer + batch_jobs = [] + if self._job_ids: + batch_jobs.extend( + self._batch_system.get_batch_jobs_from_ids(list(self._job_ids)) + ) + if self._all: + batch_jobs.extend( + self._batch_system.get_unfinished_batch_jobs(self._user, self._server) + ) - if self._jobs: - for job in self._jobs: - targets.append( - lambda j=job: _resolve_and_prepare(lambda: Informer.from_job_id(j)) - ) - else: - info_files = get_info_files(self._directory) - if not info_files: + if batch_jobs: + targets.extend(self._build_targets_from_batch_jobs(batch_jobs)) + + if self._directories: + targets.extend(self._build_targets_from_files(list(self._directories))) + + if not targets: + if not self._job_ids and not self._all: raise QQError("No qq job info file found.") - for info in info_files: - targets.append( - lambda f=info: _resolve_and_prepare(lambda: Informer.from_file(f)) - ) + raise QQError("No jobs found.") return targets + def _build_targets_from_batch_jobs( + self, + jobs: list[BatchJobInterface], + ) -> list[Callable[[], Informer]]: + """ + Build preparation targets from a list of batch jobs. + + Args: + jobs (list[BatchJobInterface]): The batch jobs to resolve. + + Returns: + list[Callable[[], Informer]]: Per-job preparation callables. + """ + return [lambda bj=batch_job: Informer.from_batch_job(bj) for batch_job in jobs] + + def _build_targets_from_files( + self, + directories: list[Path], + ) -> list[Callable[[], Informer]]: + """ + Build preparation targets from qq info files across multiple directories. + + Directories are searched in order. Each target loads an `Informer` + from a file and queries the batch system for its info individually. + + Args: + directories (list[Path]): Directories to search for info files. + + Returns: + list[Callable[[], Informer]]: Per-job preparation callables. + """ + + def _resolve_and_prepare(path: Path) -> Informer: + informer = Informer.from_file(path) + informer.load_batch_info() + return informer + + info_files: list[Path] = [] + for directory in directories: + info_files.extend(get_info_files(directory)) + + return [lambda f=info_file: _resolve_and_prepare(f) for info_file in info_files] + def _run_pipeline(self, targets: list[Callable[[], Informer]]) -> None: """ Run the prepare-then-execute pipeline. diff --git a/src/qq_lib/go/cli.py b/src/qq_lib/go/cli.py index b332377..1f460f1 100644 --- a/src/qq_lib/go/cli.py +++ b/src/qq_lib/go/cli.py @@ -1,12 +1,13 @@ # Released under MIT License. # Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab +from pathlib import Path from typing import NoReturn import click from rich.console import Console -from qq_lib.core.click_format import GNUHelpColorsCommand +from qq_lib.core.click_format import QQOperatorCommand from qq_lib.core.command_runner import CommandRunner from qq_lib.core.config import CFG from qq_lib.core.error import ( @@ -28,18 +29,20 @@ @click.command( short_help="Open a shell in a job's working directory.", help=f"""Open a new shell in the working directory of the specified qq job, or in the -working directory of the job submitted from the current directory. +working directories of qq jobs found in the specified directories. {click.style("JOB_ID", fg="green")} One or more IDs of jobs whose working directories should be entered. Optional. -If no JOB_ID is specified, `{CFG.binary_name} go` searches for qq jobs in the current directory. +If no JOB_ID and no directory are specified, `{CFG.binary_name} go` searches for qq jobs in the current directory. If multiple suitable jobs are provided or found, `{CFG.binary_name} go` opens a shell for each job in turn. +You can combine JOB_IDs with directories. All JOB_IDs must be specified before the `--dir` option. + Uses `cd` for local directories or `ssh` if the working directory is on a remote host. Note that this command does not change the working directory of the current shell; it always opens a new shell at the destination. """, - cls=GNUHelpColorsCommand, + cls=QQOperatorCommand, help_options_color="bright_blue", ) @click.argument( @@ -50,12 +53,37 @@ default=None, nargs=-1, ) -def go(jobs: tuple[str, ...]) -> NoReturn: +@click.option( + "-d", + "--dir", + type=click.Path(exists=True, file_okay=False, path_type=Path), + multiple=True, + help="One or more directories to search for qq jobs in. Supports globs.", +) +@click.option( + "-a", + "--all", + is_flag=True, + help="Open a shell in the working directories of all your unfinished jobs.", +) +@click.option( + "-s", + "--server", + default=None, + help="Operate on jobs on the specified batch server. If not specified, the current server is used. Only used with --all.", +) +def go( + jobs: tuple[str, ...], dir: tuple[Path, ...], all: bool, server: str | None +) -> NoReturn: """ - Go to the working directory (directories) of the specified qq job(s) or qq job(s) submitted from this directory. + Go to the working directory (directories) of the specified qq job(s) + or qq job(s) submitted from the specified directories. """ CommandRunner( jobs, + dir, + all, + server, _go_to_job, logger, n_threads=CFG.parallelization_options.job_info_max_threads, diff --git a/src/qq_lib/info/cli.py b/src/qq_lib/info/cli.py index a968db5..6879572 100644 --- a/src/qq_lib/info/cli.py +++ b/src/qq_lib/info/cli.py @@ -1,12 +1,13 @@ # Released under MIT License. # Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab +from pathlib import Path from typing import NoReturn import click from rich.console import Console -from qq_lib.core.click_format import GNUHelpColorsCommand +from qq_lib.core.click_format import QQOperatorCommand from qq_lib.core.command_runner import CommandRunner from qq_lib.core.config import CFG from qq_lib.core.error import QQError @@ -18,15 +19,32 @@ logger = get_logger(__name__) +# TODO: remove before version 1.0 +# This is to help users to adjust to the breaking change in version 0.12. +class InfoCommand(QQOperatorCommand): + def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: + if "-s" in args: + next_arg_index = args.index("-s") + 1 + next_arg = args[next_arg_index] if next_arg_index < len(args) else None + if next_arg is None or next_arg.startswith("-"): + raise click.UsageError( + "'-s' now means '--server', not '--short'. " + "Use '-s ' to specify a batch server. " + "Or '--short' to display only the job ID and current state." + ) + return super().parse_args(ctx, args) + + @click.command( short_help="Display information about a job.", - help=f"""Display information about the state and properties of the specified qq jobs, -or of qq jobs found in the current directory. + help=f"""Display information about the state and properties of the specified qq jobs or of qq jobs found in the specified directories. {click.style("JOB_ID", fg="green")} One or more IDs of jobs to display information for. Optional. -If no JOB_ID is specified, `{CFG.binary_name} info` searches for qq jobs in the current directory.""", - cls=GNUHelpColorsCommand, +If no JOB_ID and no directory are specified, `{CFG.binary_name} info` searches for qq jobs in the current directory. + +You can combine JOB_IDs with directories. All JOB_IDs must be specified before the `--dir` option.""", + cls=InfoCommand, help_options_color="bright_blue", ) @click.argument( @@ -36,14 +54,42 @@ nargs=-1, ) @click.option( - "-s", "--short", is_flag=True, help="Display only the job ID and current state." + "-d", + "--dir", + type=click.Path(exists=True, file_okay=False, path_type=Path), + multiple=True, + help="One or more directories to search for qq jobs in. Supports globs.", +) +@click.option( + "-a", + "--all", + is_flag=True, + help="Print info for all your unfinished jobs.", +) +@click.option( + "-s", + "--server", + default=None, + help="Collect jobs from the specified batch server. If not specified, the current server is used. Only used with --all.", +) +@click.option( + "--short", is_flag=True, help="Display only the job ID and current state." ) -def info(jobs: tuple[str, ...], short: bool) -> NoReturn: +def info( + jobs: tuple[str, ...], + dir: tuple[Path, ...], + all: bool, + server: str | None, + short: bool, +) -> NoReturn: """ Get information about the specified qq jobs or qq jobs submitted from this directory. """ CommandRunner( jobs, + dir, + all, + server, _info_for_job, logger, short, diff --git a/src/qq_lib/info/informer.py b/src/qq_lib/info/informer.py index 988030f..94d07fd 100644 --- a/src/qq_lib/info/informer.py +++ b/src/qq_lib/info/informer.py @@ -1,9 +1,9 @@ # Released under MIT License. # Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab -from datetime import datetime -from pathlib import Path -from typing import Self +from __future__ import annotations + +from typing import TYPE_CHECKING, Self from qq_lib.batch.interface import BatchInterface, BatchJobInterface from qq_lib.core.common import construct_info_file_path @@ -12,6 +12,10 @@ from qq_lib.properties.info import Info from qq_lib.properties.states import BatchState, NaiveState, RealState +if TYPE_CHECKING: + from datetime import datetime + from pathlib import Path + logger = get_logger(__name__) @@ -119,6 +123,26 @@ def from_batch_job(cls, batch_job: BatchJobInterface) -> Self: informer._batch_info = batch_job return informer + @staticmethod + def set_batch_info_in_bulk(informers: list[Informer]) -> None: + """ + Set the batch info for a list of informers in bulk, + by querying the batch system as few times as possible. + + If the corresponding batch job no longer exists, batch info is still set, + but the BatchJobInterface is empty. + + Args: + informers (list[Informer]): The list of informers to set the batch info for. + """ + if not informers: + return + + job_ids = [informer.info.job_id for informer in informers] + batch_jobs = informers[0].batch_system.get_batch_jobs_from_ids(job_ids) + for informer, batch_job in zip(informers, batch_jobs): + informer._batch_info = batch_job + def to_file(self, file: Path, host: str | None = None) -> None: """ Export the job information to a file. diff --git a/src/qq_lib/kill/cli.py b/src/qq_lib/kill/cli.py index fcf0680..ee9d769 100644 --- a/src/qq_lib/kill/cli.py +++ b/src/qq_lib/kill/cli.py @@ -1,12 +1,13 @@ # Released under MIT License. # Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab +from pathlib import Path from typing import NoReturn import click from rich.console import Console -from qq_lib.core.click_format import GNUHelpColorsCommand +from qq_lib.core.click_format import QQOperatorCommand from qq_lib.core.command_runner import CommandRunner from qq_lib.core.common import ( yes_or_no_prompt, @@ -30,11 +31,13 @@ @click.command( short_help="Terminate a job.", - help=f"""Terminate the specified qq jobs, or all qq jobs in the current directory. + help=f"""Terminate the specified qq jobs or all qq jobs in the specified directories. {click.style("JOB_ID", fg="green")} One or more IDs of jobs to terminate. Optional. -If no JOB_ID is specified, `{CFG.binary_name} kill` searches for qq jobs in the current directory. +If no JOB_ID and no directory are specified, `{CFG.binary_name} kill` searches for qq jobs in the current directory. + +You can combine JOB_IDs with directories. All JOB_IDs must be specified before the `--dir` option. By default, `{CFG.binary_name} kill` prompts for confirmation before terminating a job. @@ -43,7 +46,7 @@ When the `--force` flag is used, `{CFG.binary_name} kill` attempts to terminate any job regardless of its state, including jobs that are, according to qq, already finished or killed. This can be useful for removing lingering or stuck jobs.""", - cls=GNUHelpColorsCommand, + cls=QQOperatorCommand, help_options_color="bright_blue", ) @click.argument( @@ -55,16 +58,42 @@ nargs=-1, ) @click.option( - "-y", "--yes", is_flag=True, help="Terminate the job without confirmation." + "-d", + "--dir", + type=click.Path(exists=True, file_okay=False, path_type=Path), + multiple=True, + help="One or more directories to search for qq jobs in. Supports globs.", +) +@click.option( + "-a", + "--all", + is_flag=True, + help="Terminate all your unfinished jobs.", +) +@click.option( + "-s", + "--server", + default=None, + help="Kill jobs on the specified batch server. If not specified, the current server is used. Only used with --all.", +) +@click.option( + "-y", "--yes", is_flag=True, help="Terminate the job(s) without confirmation." ) @click.option( "--force", is_flag=True, - help="Terminate the job forcibly, ignoring its current state and without confirmation.", + help="Terminate the job(s) forcibly, ignoring its current state and without confirmation.", ) -def kill(jobs: tuple[str, ...], yes: bool = False, force: bool = False) -> NoReturn: +def kill( + jobs: tuple[str, ...], + dir: tuple[Path, ...], + all: bool, + server: str | None, + yes: bool = False, + force: bool = False, +) -> NoReturn: """ - Terminate the specified qq job(s) or qq job(s) submitted from the current directory. + Terminate the specified qq job(s) or qq job(s) submitted from the specified directories. Details Killing a job sets its state to "killed". This is handled either by `qq kill` or @@ -88,6 +117,9 @@ def kill(jobs: tuple[str, ...], yes: bool = False, force: bool = False) -> NoRet """ CommandRunner( jobs, + dir, + all, + server, kill_job, logger, force, diff --git a/src/qq_lib/killall/_deprecated.py b/src/qq_lib/killall/_deprecated.py new file mode 100644 index 0000000..3e55113 --- /dev/null +++ b/src/qq_lib/killall/_deprecated.py @@ -0,0 +1,119 @@ +# Released under MIT License. +# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab + + +import getpass +import sys +from collections.abc import Iterable +from typing import NoReturn + +import click + +from qq_lib.batch.interface import BatchInterface +from qq_lib.batch.interface.job import BatchJobInterface +from qq_lib.core.click_format import GNUHelpColorsCommand +from qq_lib.core.common import translate_server, yes_or_no_prompt +from qq_lib.core.config import CFG +from qq_lib.core.error import QQError, QQJobMismatchError, QQNotSuitableError +from qq_lib.core.logger import get_logger +from qq_lib.core.repeater import Repeater +from qq_lib.info.informer import Informer +from qq_lib.kill.cli import kill_job + +logger = get_logger(__name__) + + +@click.command( + short_help="Terminate all your jobs.", + help="""Terminate all your submitted qq jobs. + +This command is only able to terminate qq jobs, all other jobs are not affected by it.""", + cls=GNUHelpColorsCommand, + help_options_color="bright_blue", +) +@click.option( + "-y", "--yes", is_flag=True, help="Terminate the jobs without confirmation." +) +@click.option( + "--force", + is_flag=True, + help="Terminate the jobs forcibly, ignoring their current state and without confirmation.", +) +@click.option( + "-s", + "--server", + default=None, + help="Termine all your jobs on the specified batch server. If not specified, the current server is used.", +) +def killall( + yes: bool = False, force: bool = False, server: str | None = None +) -> NoReturn: + try: + BatchSystem = BatchInterface.from_env_var_or_guess() + + if server: + server = translate_server(server) + + jobs = BatchSystem.get_unfinished_batch_jobs(getpass.getuser(), server) + if not jobs: + logger.info("You have no active jobs. Nothing to kill.") + sys.exit(0) + + informers = _informers_from_jobs(jobs) + if not informers: + logger.info( + f"You have no active qq jobs (and {len(jobs)} other jobs). Nothing to kill." + ) + sys.exit(0) + + if ( + yes + or force + or yes_or_no_prompt( + f"You have {len(informers)} active qq job{'s' if len(informers) > 1 else ''}. Do you want to kill {'them' if len(informers) > 1 else 'it'}?" + ) + ): + # TODO: this should be done in parallel + repeater = Repeater( + informers, + kill_job, + force=force, + yes=True, # assume yes + ) + repeater.on_exception(QQNotSuitableError, _log_error_and_continue) + repeater.on_exception(QQError, _log_error_and_continue) + repeater.run() + else: + logger.info("Operation aborted.") + + sys.exit(0) + except QQError as e: + logger.error(e) + sys.exit(CFG.exit_codes.default) + except Exception as e: + logger.critical(e, exc_info=True, stack_info=True) + sys.exit(CFG.exit_codes.unexpected_error) + + +def _informers_from_jobs(jobs: Iterable[BatchJobInterface]) -> list[Informer]: + """ + Get informers from the provided batch jobs. Ignore non-qq jobs. + """ + informers = [] + for job in jobs: + try: + informers.append(Informer.from_batch_job(job)) + except (QQError, QQJobMismatchError): + continue + + return informers + + +def _log_error_and_continue( + exception: BaseException, + _metadata: Repeater, +) -> None: + """ + Log error as error and continue the execution. + """ + logger.error(exception) diff --git a/src/qq_lib/killall/cli.py b/src/qq_lib/killall/cli.py index 3e55113..fdff089 100644 --- a/src/qq_lib/killall/cli.py +++ b/src/qq_lib/killall/cli.py @@ -2,118 +2,44 @@ # Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab -import getpass import sys -from collections.abc import Iterable from typing import NoReturn import click -from qq_lib.batch.interface import BatchInterface -from qq_lib.batch.interface.job import BatchJobInterface from qq_lib.core.click_format import GNUHelpColorsCommand -from qq_lib.core.common import translate_server, yes_or_no_prompt from qq_lib.core.config import CFG -from qq_lib.core.error import QQError, QQJobMismatchError, QQNotSuitableError from qq_lib.core.logger import get_logger -from qq_lib.core.repeater import Repeater -from qq_lib.info.informer import Informer -from qq_lib.kill.cli import kill_job logger = get_logger(__name__) @click.command( - short_help="Terminate all your jobs.", - help="""Terminate all your submitted qq jobs. - -This command is only able to terminate qq jobs, all other jobs are not affected by it.""", + short_help="Terminate all your jobs. DEPRECATED. Use `qq kill --all` instead.", + help=f"Terminate all your submitted qq jobs. {click.style('DEPRECATED', bold=True, fg='red')}. Use `qq kill --all` instead.", cls=GNUHelpColorsCommand, help_options_color="bright_blue", ) @click.option( - "-y", "--yes", is_flag=True, help="Terminate the jobs without confirmation." + "-y", + "--yes", + is_flag=True, + help=f"{click.style('DEPRECATED', bold=True, fg='red')}. Use `qq kill --all` instead.", ) @click.option( "--force", is_flag=True, - help="Terminate the jobs forcibly, ignoring their current state and without confirmation.", + help=f"{click.style('DEPRECATED', bold=True, fg='red')}. Use `qq kill --all` instead.", ) @click.option( "-s", "--server", default=None, - help="Termine all your jobs on the specified batch server. If not specified, the current server is used.", + help=f"{click.style('DEPRECATED', bold=True, fg='red')}. Use `qq kill --all` instead.", ) def killall( yes: bool = False, force: bool = False, server: str | None = None ) -> NoReturn: - try: - BatchSystem = BatchInterface.from_env_var_or_guess() - - if server: - server = translate_server(server) - - jobs = BatchSystem.get_unfinished_batch_jobs(getpass.getuser(), server) - if not jobs: - logger.info("You have no active jobs. Nothing to kill.") - sys.exit(0) - - informers = _informers_from_jobs(jobs) - if not informers: - logger.info( - f"You have no active qq jobs (and {len(jobs)} other jobs). Nothing to kill." - ) - sys.exit(0) - - if ( - yes - or force - or yes_or_no_prompt( - f"You have {len(informers)} active qq job{'s' if len(informers) > 1 else ''}. Do you want to kill {'them' if len(informers) > 1 else 'it'}?" - ) - ): - # TODO: this should be done in parallel - repeater = Repeater( - informers, - kill_job, - force=force, - yes=True, # assume yes - ) - repeater.on_exception(QQNotSuitableError, _log_error_and_continue) - repeater.on_exception(QQError, _log_error_and_continue) - repeater.run() - else: - logger.info("Operation aborted.") - - sys.exit(0) - except QQError as e: - logger.error(e) - sys.exit(CFG.exit_codes.default) - except Exception as e: - logger.critical(e, exc_info=True, stack_info=True) - sys.exit(CFG.exit_codes.unexpected_error) - - -def _informers_from_jobs(jobs: Iterable[BatchJobInterface]) -> list[Informer]: - """ - Get informers from the provided batch jobs. Ignore non-qq jobs. - """ - informers = [] - for job in jobs: - try: - informers.append(Informer.from_batch_job(job)) - except (QQError, QQJobMismatchError): - continue - - return informers - - -def _log_error_and_continue( - exception: BaseException, - _metadata: Repeater, -) -> None: - """ - Log error as error and continue the execution. - """ - logger.error(exception) + _ = yes, force, server + logger.error("This command is deprecated. Use `qq kill --all` instead.") + sys.exit(CFG.exit_codes.default) diff --git a/src/qq_lib/respawn/cli.py b/src/qq_lib/respawn/cli.py index c77fa2c..6f171c5 100644 --- a/src/qq_lib/respawn/cli.py +++ b/src/qq_lib/respawn/cli.py @@ -1,12 +1,13 @@ # Released under MIT License. # Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab +from pathlib import Path from typing import NoReturn import click from rich.console import Console -from qq_lib.core.click_format import GNUHelpColorsCommand +from qq_lib.core.click_format import QQOperatorCommand from qq_lib.core.command_runner import CommandRunner from qq_lib.core.config import CFG from qq_lib.core.error import QQError, QQNotSuitableError @@ -24,16 +25,18 @@ @click.command( short_help="Respawn a failed/killed job.", - help=f"""Respawn the specified qq jobs, or all qq jobs in the current directory. + help=f"""Respawn the specified qq jobs, or all qq jobs in the specified directories. {click.style("JOB_ID", fg="green")} One or more IDs of jobs to respawn. Optional. -If no JOB_ID is specified, `{CFG.binary_name} respawn` searches for qq jobs in the current directory. +If no JOB_ID and no directory are specified, `{CFG.binary_name} respawn` searches for qq jobs in the current directory. + +You can combine JOB_IDs with directories. All JOB_IDs must be specified before the `--dir` option. Respawning resubmits a failed or killed job to the batch system with its original parameters. This is useful when a job fails due to a node failure, an unexpected walltime limit, a random crash, or various other types of premature termination.""", - cls=GNUHelpColorsCommand, + cls=QQOperatorCommand, help_options_color="bright_blue", ) @click.argument( @@ -44,9 +47,19 @@ default=None, nargs=-1, ) -def respawn(jobs: tuple[str, ...]) -> NoReturn: +@click.option( + "-d", + "--dir", + type=click.Path(exists=True, file_okay=False, path_type=Path), + multiple=True, + help="One or more directories to search for qq jobs in. Supports globs.", +) +def respawn(jobs: tuple[str, ...], dir: tuple[Path, ...]) -> NoReturn: CommandRunner( jobs, + dir, + False, + None, _respawn_job, logger, n_threads=CFG.parallelization_options.job_info_max_threads, diff --git a/src/qq_lib/sync/cli.py b/src/qq_lib/sync/cli.py index 76d5297..1a97a33 100644 --- a/src/qq_lib/sync/cli.py +++ b/src/qq_lib/sync/cli.py @@ -2,12 +2,13 @@ # Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab import re +from pathlib import Path from typing import NoReturn import click from rich.console import Console -from qq_lib.core.click_format import GNUHelpColorsCommand +from qq_lib.core.click_format import QQOperatorCommand from qq_lib.core.command_runner import CommandRunner from qq_lib.core.config import CFG from qq_lib.core.error import ( @@ -30,17 +31,19 @@ @click.command( short_help="Fetch files from a job's working directory.", help=f"""Fetch files from the working directory of the specified qq job, or from the -working directory of the job submitted from the current directory. +working directories of the jobs submitted from the specified directories. {click.style("JOB_ID", fg="green")} One or more IDs of jobs whose working directory files should be fetched. Optional. -If no JOB_ID is specified, `{CFG.binary_name} sync` searches for qq jobs in the current directory. +If no JOB_ID and no directory are specified, `{CFG.binary_name} sync` searches for qq jobs in the current directory. If multiple suitable jobs are provided or found, `{CFG.binary_name} sync` fetches files from each job in turn. Files fetched from later jobs may overwrite files from earlier jobs in the input directory. +You can combine JOB_IDs with directories. All JOB_IDs must be specified before the `--dir` option. + Files are copied from the job's working directory to its input directory, not to the current directory. """, - cls=GNUHelpColorsCommand, + cls=QQOperatorCommand, help_options_color="bright_blue", ) @click.argument( @@ -51,6 +54,25 @@ default=None, nargs=-1, ) +@click.option( + "-d", + "--dir", + type=click.Path(exists=True, file_okay=False, path_type=Path), + multiple=True, + help="One or more directories to search for qq jobs in. Supports globs.", +) +@click.option( + "-a", + "--all", + is_flag=True, + help="Fetch files for all your unfinished jobs.", +) +@click.option( + "-s", + "--server", + default=None, + help="Operate on jobs from the specified batch server. If not specified, the current server is used. Only used with --all.", +) @click.option( "-f", "--files", @@ -59,13 +81,22 @@ help="""A colon-, comma-, or space-separated list of files or directories to fetch. If not specified, the entire content of the working directory is fetched.""", ) -def sync(jobs: tuple[str, ...], files: str | None) -> NoReturn: +def sync( + jobs: tuple[str, ...], + dir: tuple[Path, ...], + all: bool, + server: str | None, + files: str | None, +) -> NoReturn: """ Fetch files from the working directory (directories) of the specified qq job(s) or of qq job(s) submitted from this directory. """ CommandRunner( jobs, + dir, + all, + server, _sync_job, logger, _split_files(files), diff --git a/src/qq_lib/wipe/cli.py b/src/qq_lib/wipe/cli.py index 852437b..9de110d 100644 --- a/src/qq_lib/wipe/cli.py +++ b/src/qq_lib/wipe/cli.py @@ -1,12 +1,13 @@ # Released under MIT License. # Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab +from pathlib import Path from typing import NoReturn import click from rich.console import Console -from qq_lib.core.click_format import GNUHelpColorsCommand +from qq_lib.core.click_format import QQOperatorCommand from qq_lib.core.command_runner import CommandRunner from qq_lib.core.common import ( yes_or_no_prompt, @@ -30,11 +31,13 @@ @click.command( short_help="Delete the working directory of a job.", - help=f"""Delete the working directories of the specified qq jobs, or of all qq jobs in the current directory. + help=f"""Delete the working directories of the specified qq jobs, or of all qq jobs in the specified directories. {click.style("JOB_ID", fg="green")} One or more IDs of jobs whose working directories should be deleted. Optional. -If no JOB_ID is specified, `{CFG.binary_name} wipe` searches for qq jobs in the current directory. +If no JOB_ID and no directory are specified, `{CFG.binary_name} wipe` searches for qq jobs in the current directory. + +You can combine JOB_IDs with directories. All JOB_IDs must be specified before the `--dir` option. By default, `{CFG.binary_name} wipe` prompts for confirmation before deleting the working directory. @@ -44,7 +47,7 @@ You should be very careful when using this option as it may delete useful data or cause your job to crash! If the working directory matches the input directory, `{CFG.binary_name} wipe` will never delete it, even if you use the `--force` flag.""", - cls=GNUHelpColorsCommand, + cls=QQOperatorCommand, help_options_color="bright_blue", ) @click.argument( @@ -55,6 +58,13 @@ default=None, nargs=-1, ) +@click.option( + "-d", + "--dir", + type=click.Path(exists=True, file_okay=False, path_type=Path), + multiple=True, + help="One or more directories to search for qq jobs in. Supports globs.", +) @click.option( "-y", "--yes", @@ -66,12 +76,17 @@ is_flag=True, help="Delete the working directory of the job forcibly, ignoring its current state and without confirmation.", ) -def wipe(jobs: tuple[str, ...], yes: bool = False, force: bool = False) -> NoReturn: +def wipe( + jobs: tuple[str, ...], dir: tuple[Path, ...], yes: bool = False, force: bool = False +) -> NoReturn: """ Delete the working directory of the specified qq job or qq job(s) submitted from the current directory. """ CommandRunner( jobs, + dir, + False, + None, _wipe_work_dir, logger, force, diff --git a/tests/batch/pbs/test_pbs.py b/tests/batch/pbs/test_pbs.py index cdb507f..0f9f4fd 100644 --- a/tests/batch/pbs/test_pbs.py +++ b/tests/batch/pbs/test_pbs.py @@ -6,6 +6,7 @@ import os import shutil import socket +import subprocess from pathlib import Path from unittest.mock import MagicMock, patch @@ -1343,7 +1344,9 @@ def test_get_batch_jobs_using_command_success(sample_multi_dump_file): returncode=0, stdout=sample_multi_dump_file, stderr="" ) - jobs = PBS._get_batch_jobs_using_command("fake command - unused", False, True) + jobs = PBS._get_batch_jobs_using_command( + "fake command - unused", False, True, False + ) assert len(jobs) == 3 assert all(isinstance(job, PBSJob) for job in jobs) @@ -1367,11 +1370,12 @@ def test_get_batch_jobs_using_command_success(sample_multi_dump_file): ] mock_run.assert_called_once_with( - ["bash"], + ["stdbuf", "-oL", "-eL", "bash"], input="fake command - unused", text=True, check=False, - capture_output=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, errors="replace", ) @@ -1413,7 +1417,9 @@ def test_get_batch_jobs_using_command_filter_out_completed_jobs( returncode=0, stdout=sample_multi_dump_file_with_completed_job, stderr="" ) - jobs = PBS._get_batch_jobs_using_command("fake command - unused", False, True) + jobs = PBS._get_batch_jobs_using_command( + "fake command - unused", False, True, False + ) assert len(jobs) == 2 assert all(isinstance(job, PBSJob) for job in jobs) @@ -1434,11 +1440,12 @@ def test_get_batch_jobs_using_command_filter_out_completed_jobs( ] mock_run.assert_called_once_with( - ["bash"], + ["stdbuf", "-oL", "-eL", "bash"], input="fake command - unused", text=True, check=False, - capture_output=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, errors="replace", ) @@ -1480,7 +1487,9 @@ def test_get_batch_jobs_using_command_filter_out_top_level_array_jobs( returncode=0, stdout=sample_multi_dump_file_with_array_job, stderr="" ) - jobs = PBS._get_batch_jobs_using_command("fake command - unused", False, False) + jobs = PBS._get_batch_jobs_using_command( + "fake command - unused", False, False, False + ) assert len(jobs) == 2 assert all(isinstance(job, PBSJob) for job in jobs) @@ -1501,11 +1510,12 @@ def test_get_batch_jobs_using_command_filter_out_top_level_array_jobs( ] mock_run.assert_called_once_with( - ["bash"], + ["stdbuf", "-oL", "-eL", "bash"], input="fake command - unused", text=True, check=False, - capture_output=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, errors="replace", ) @@ -1518,7 +1528,9 @@ def test_get_batch_jobs_using_command_keep_top_level_array_jobs( returncode=0, stdout=sample_multi_dump_file_with_array_job, stderr="" ) - jobs = PBS._get_batch_jobs_using_command("fake command - unused", False, True) + jobs = PBS._get_batch_jobs_using_command( + "fake command - unused", False, True, False + ) assert len(jobs) == 3 assert all(isinstance(job, PBSJob) for job in jobs) @@ -1542,11 +1554,12 @@ def test_get_batch_jobs_using_command_keep_top_level_array_jobs( ] mock_run.assert_called_once_with( - ["bash"], + ["stdbuf", "-oL", "-eL", "bash"], input="fake command - unused", text=True, check=False, - capture_output=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, errors="replace", ) @@ -1554,13 +1567,79 @@ def test_get_batch_jobs_using_command_keep_top_level_array_jobs( def test_get_batch_jobs_using_command_nonzero_returncode(): with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock( - returncode=1, stdout="", stderr="Some error occurred" + returncode=1, stdout="Some error occurred", stderr="" ) with pytest.raises( QQError, match="Could not retrieve information about jobs: Some error occurred", ): - PBS._get_batch_jobs_using_command("will not be used", True, True) + PBS._get_batch_jobs_using_command("will not be used", True, True, False) + + +@pytest.fixture +def sample_multi_dump_file_with_error(): + return """Job Id: 123456.fake-cluster.example.com + Job_Name = example_job_1 + Job_Owner = user@EXAMPLE + resources_used.cpupercent = 50 + resources_used.ncpus = 4 + job_state = R + queue = gpu + +qstat: Unknown Job Id 123457.fake-cluster.example.com + +Job Id: 123458.fake-cluster.example.com + Job_Name = example_job_3 + Job_Owner = user@EXAMPLE + resources_used.cpupercent = 100 + resources_used.ncpus = 16 + job_state = H + queue = gpu +""" + + +def test_get_batch_jobs_using_command_with_ignore_exit_code( + sample_multi_dump_file_with_error, +): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=153, stdout=sample_multi_dump_file_with_error, stderr="" + ) + + jobs = PBS._get_batch_jobs_using_command( + "fake command - unused", False, True, True + ) + + assert len(jobs) == 3 + assert all(isinstance(job, PBSJob) for job in jobs) + + expected_ids = [ + "123456.fake-cluster.example.com", + "123457.fake-cluster.example.com", + "123458.fake-cluster.example.com", + ] + assert [job._job_id for job in jobs] == expected_ids + + assert [job._info.get("Job_Name") for job in jobs] == [ + "example_job_1", + None, + "example_job_3", + ] + assert [job._info.get("job_state") for job in jobs] == [ + "R", + None, + "H", + ] + + mock_run.assert_called_once_with( + ["stdbuf", "-oL", "-eL", "bash"], + input="fake command - unused", + text=True, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + errors="replace", + ) @pytest.mark.parametrize( @@ -2049,3 +2128,235 @@ def test_translate_output_server_warns_when_no_output_host(): assert server in mock_warning.call_args[0][0] assert result == "-j eo -e /mnt/shared/home/alice/jobs/myjob.qqout" + + +@pytest.fixture() +def mock_jobs() -> list[PBSJob]: + return [MagicMock(spec=PBSJob), MagicMock(spec=PBSJob)] + + +def test_pbs_get_batch_jobs_from_ids_returns_empty_for_no_ids(): + result = PBS.get_batch_jobs_from_ids([]) + + assert result == [] + + +def test_pbs_get_batch_jobs_from_ids_calls_correct_command(mock_jobs): + with patch( + "qq_lib.batch.pbs.PBS._get_batch_jobs_using_command", return_value=mock_jobs + ) as mock_call: + PBS.get_batch_jobs_from_ids(["111", "222"]) + + mock_call.assert_called_once_with( + "qstat -fxw 111 222", + include_completed=True, + include_top_level_array=True, + ignore_exit_code=True, + ) + + +def test_pbs_get_batch_jobs_from_ids_returns_result(mock_jobs): + with patch( + "qq_lib.batch.pbs.PBS._get_batch_jobs_using_command", return_value=mock_jobs + ): + result = PBS.get_batch_jobs_from_ids(["111", "222"]) + + assert result is mock_jobs + + +def test_pbs_get_batch_jobs_from_ids_single_id(mock_jobs): + with patch( + "qq_lib.batch.pbs.PBS._get_batch_jobs_using_command", return_value=mock_jobs + ) as mock_call: + PBS.get_batch_jobs_from_ids(["111"]) + + command = mock_call.call_args[0][0] + assert command == "qstat -fxw 111" + + +def test_pbs_get_unfinished_batch_jobs_calls_correct_command(mock_jobs): + with patch( + "qq_lib.batch.pbs.PBS._get_batch_jobs_using_command", return_value=mock_jobs + ) as mock_call: + PBS.get_unfinished_batch_jobs("alice") + + mock_call.assert_called_once_with( + "qstat -fwtu alice", + include_completed=False, + include_top_level_array=True, + ignore_exit_code=False, + ) + + +def test_pbs_get_unfinished_batch_jobs_appends_server(mock_jobs): + with patch( + "qq_lib.batch.pbs.PBS._get_batch_jobs_using_command", return_value=mock_jobs + ) as mock_call: + PBS.get_unfinished_batch_jobs("alice", server="server") + + mock_call.assert_called_once_with( + "qstat -fwtu alice @server", + include_completed=False, + include_top_level_array=True, + ignore_exit_code=False, + ) + + +def test_pbs_get_unfinished_batch_jobs_no_server_no_at_symbol(mock_jobs): + with patch( + "qq_lib.batch.pbs.PBS._get_batch_jobs_using_command", return_value=mock_jobs + ) as mock_call: + PBS.get_unfinished_batch_jobs("alice") + + command = mock_call.call_args[0][0] + assert "@" not in command + + +def test_pbs_get_unfinished_batch_jobs_returns_result(mock_jobs): + with patch( + "qq_lib.batch.pbs.PBS._get_batch_jobs_using_command", return_value=mock_jobs + ): + result = PBS.get_unfinished_batch_jobs("alice") + + assert result is mock_jobs + + +def test_pbs_get_batch_jobs_calls_correct_command(mock_jobs): + with patch( + "qq_lib.batch.pbs.PBS._get_batch_jobs_using_command", return_value=mock_jobs + ) as mock_call: + PBS.get_batch_jobs("alice") + + mock_call.assert_called_once_with( + "qstat -fwxtu alice", + include_completed=True, + include_top_level_array=True, + ignore_exit_code=False, + ) + + +def test_pbs_get_batch_jobs_appends_server(mock_jobs): + with patch( + "qq_lib.batch.pbs.PBS._get_batch_jobs_using_command", return_value=mock_jobs + ) as mock_call: + PBS.get_batch_jobs("alice", server="server") + + mock_call.assert_called_once_with( + "qstat -fwxtu alice @server", + include_completed=True, + include_top_level_array=True, + ignore_exit_code=False, + ) + + +def test_pbs_get_batch_jobs_no_server_no_at_symbol(mock_jobs): + with patch( + "qq_lib.batch.pbs.PBS._get_batch_jobs_using_command", return_value=mock_jobs + ) as mock_call: + PBS.get_batch_jobs("alice") + + command = mock_call.call_args[0][0] + assert "@" not in command + + +def test_pbs_get_batch_jobs_returns_result(mock_jobs): + with patch( + "qq_lib.batch.pbs.PBS._get_batch_jobs_using_command", return_value=mock_jobs + ): + result = PBS.get_batch_jobs("alice") + + assert result is mock_jobs + + +def test_pbs_get_all_unfinished_batch_jobs_calls_correct_command(mock_jobs): + with patch( + "qq_lib.batch.pbs.PBS._get_batch_jobs_using_command", return_value=mock_jobs + ) as mock_call: + PBS.get_all_unfinished_batch_jobs() + + mock_call.assert_called_once_with( + "qstat -fwt", + include_completed=False, + include_top_level_array=True, + ignore_exit_code=False, + ) + + +def test_pbs_get_all_unfinished_batch_jobs_appends_server(mock_jobs): + with patch( + "qq_lib.batch.pbs.PBS._get_batch_jobs_using_command", return_value=mock_jobs + ) as mock_call: + PBS.get_all_unfinished_batch_jobs(server="server") + + mock_call.assert_called_once_with( + "qstat -fwt @server", + include_completed=False, + include_top_level_array=True, + ignore_exit_code=False, + ) + + +def test_pbs_get_all_unfinished_batch_jobs_no_server_no_at_symbol(mock_jobs): + with patch( + "qq_lib.batch.pbs.PBS._get_batch_jobs_using_command", return_value=mock_jobs + ) as mock_call: + PBS.get_all_unfinished_batch_jobs() + + command = mock_call.call_args[0][0] + assert "@" not in command + + +def test_pbs_get_all_unfinished_batch_jobs_returns_result(mock_jobs): + with patch( + "qq_lib.batch.pbs.PBS._get_batch_jobs_using_command", return_value=mock_jobs + ): + result = PBS.get_all_unfinished_batch_jobs() + + assert result is mock_jobs + + +def test_pbs_get_all_batch_jobs_calls_correct_command(mock_jobs): + with patch( + "qq_lib.batch.pbs.PBS._get_batch_jobs_using_command", return_value=mock_jobs + ) as mock_call: + PBS.get_all_batch_jobs() + + mock_call.assert_called_once_with( + "qstat -fxwt", + include_completed=True, + include_top_level_array=True, + ignore_exit_code=False, + ) + + +def test_pbs_get_all_batch_jobs_appends_server(mock_jobs): + with patch( + "qq_lib.batch.pbs.PBS._get_batch_jobs_using_command", return_value=mock_jobs + ) as mock_call: + PBS.get_all_batch_jobs(server="myserver") + + mock_call.assert_called_once_with( + "qstat -fxwt @myserver", + include_completed=True, + include_top_level_array=True, + ignore_exit_code=False, + ) + + +def test_pbs_get_all_batch_jobs_no_server_no_at_symbol(mock_jobs): + with patch( + "qq_lib.batch.pbs.PBS._get_batch_jobs_using_command", return_value=mock_jobs + ) as mock_call: + PBS.get_all_batch_jobs() + + command = mock_call.call_args[0][0] + assert "@" not in command + + +def test_pbs_get_all_batch_jobs_returns_result(mock_jobs): + with patch( + "qq_lib.batch.pbs.PBS._get_batch_jobs_using_command", return_value=mock_jobs + ): + result = PBS.get_all_batch_jobs() + + assert result is mock_jobs diff --git a/tests/core/test_click_format.py b/tests/core/test_click_format.py index cafa86f..55f54be 100644 --- a/tests/core/test_click_format.py +++ b/tests/core/test_click_format.py @@ -5,8 +5,9 @@ from unittest.mock import MagicMock, patch import click +import pytest -from qq_lib.core.click_format import GNUHelpColorsCommand +from qq_lib.core.click_format import GlobDirectoryMixin, GNUHelpColorsCommand def test_no_resilient_parsing_leaves_args_unchanged(): @@ -198,3 +199,185 @@ def cmd(opt1, opt2, script): "foo=bar", "script.sh", ] + + +class CapturingCommand(GlobDirectoryMixin): + """Minimal concrete class that records the rewritten args instead of parsing them.""" + + def __init__(self) -> None: + self.received_args: list[str] = [] + + def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: + return super().parse_args(ctx, args) + + # super().parse_args() will call this + def _parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: + _ = ctx + self.received_args = args + return args + + +class _Base: + def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: + _ = ctx + return args + + +class CommandForTesting(GlobDirectoryMixin, _Base): + def __init__(self) -> None: + self.received_args: list[str] = [] + + def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: + result = super().parse_args(ctx, args) + self.received_args = result + return result + + +@pytest.fixture() +def ctx() -> click.Context: + cmd = click.Command("test") + return click.Context(cmd) + + +@pytest.fixture() +def command() -> CommandForTesting: + return CommandForTesting() + + +def test_glob_directory_mixin_single_short_flag(command, ctx, tmp_path): + d = tmp_path / "jobs" + d.mkdir() + + command.parse_args(ctx, ["-d", str(d)]) + + assert command.received_args == ["-d", str(d)] + + +def test_glob_directory_mixin_single_long_flag(command, ctx, tmp_path): + d = tmp_path / "jobs" + d.mkdir() + + command.parse_args(ctx, ["--dir", str(d)]) + + assert command.received_args == ["-d", str(d)] + + +def test_glob_directory_mixin_multiple_directories(command, ctx, tmp_path): + d1 = tmp_path / "jobs1" + d2 = tmp_path / "jobs2" + d1.mkdir() + d2.mkdir() + + command.parse_args(ctx, ["-d", str(d1), str(d2)]) + + assert command.received_args == ["-d", str(d1), "-d", str(d2)] + + +def test_glob_directory_mixin_multiple_directories_long_flag(command, ctx, tmp_path): + d1 = tmp_path / "jobs1" + d2 = tmp_path / "jobs2" + d1.mkdir() + d2.mkdir() + + command.parse_args(ctx, ["--dir", str(d1), str(d2)]) + + assert command.received_args == ["-d", str(d1), "-d", str(d2)] + + +def test_glob_directory_mixin_glob_expands_matching_directories(command, ctx, tmp_path): + (tmp_path / "jobs_a").mkdir() + (tmp_path / "jobs_b").mkdir() + (tmp_path / "other").mkdir() + + pattern = str(tmp_path / "jobs_*") + command.parse_args(ctx, ["-d", pattern]) + + assert command.received_args == [ + "-d", + str(tmp_path / "jobs_a"), + "-d", + str(tmp_path / "jobs_b"), + ] + + +def test_glob_directory_mixin_glob_expansion_is_sorted(command, ctx, tmp_path): + (tmp_path / "jobs_c").mkdir() + (tmp_path / "jobs_a").mkdir() + (tmp_path / "jobs_b").mkdir() + + pattern = str(tmp_path / "jobs_*") + command.parse_args(ctx, ["-d", pattern]) + + assert command.received_args == [ + "-d", + str(tmp_path / "jobs_a"), + "-d", + str(tmp_path / "jobs_b"), + "-d", + str(tmp_path / "jobs_c"), + ] + + +def test_glob_directory_mixin_no_glob_match_passes_through(command, ctx, tmp_path): + pattern = str(tmp_path / "nonexistent_*") + command.parse_args(ctx, ["-d", pattern]) + + assert command.received_args == ["-d", pattern] + + +def test_glob_directory_mixin_job_ids_before_flag_preserved(command, ctx, tmp_path): + d = tmp_path / "jobs" + d.mkdir() + + command.parse_args(ctx, ["12345", "-d", str(d)]) + + assert command.received_args == ["12345", "-d", str(d)] + + +def test_glob_directory_mixin_flag_stops_at_next_flag(command, ctx, tmp_path): + d = tmp_path / "jobs" + d.mkdir() + + command.parse_args(ctx, ["-d", str(d), "--short"]) + + assert command.received_args == ["-d", str(d), "--short"] + + +def test_glob_directory_mixin_non_directory_flags_unaffected(command, ctx): + command.parse_args(ctx, ["--short", "--all"]) + + assert command.received_args == ["--short", "--all"] + + +def test_glob_directory_mixin_empty_args(command, ctx): + command.parse_args(ctx, []) + + assert command.received_args == [] + + +def test_glob_directory_mixin_repeated_flags_each_expanded(command, ctx, tmp_path): + d1 = tmp_path / "jobs1" + d2 = tmp_path / "jobs2" + d1.mkdir() + d2.mkdir() + + command.parse_args(ctx, ["-d", str(d1), "-d", str(d2)]) + + assert command.received_args == ["-d", str(d1), "-d", str(d2)] + + +def test_glob_directory_mixin_glob_with_job_ids(command, ctx, tmp_path): + (tmp_path / "jobs_a").mkdir() + (tmp_path / "jobs_b").mkdir() + + pattern = str(tmp_path / "jobs_*") + command.parse_args(ctx, ["12345", "67890", "-d", pattern]) + + assert command.received_args == [ + "12345", + "67890", + "-d", + str(tmp_path / "jobs_a"), + "-d", + str(tmp_path / "jobs_b"), + ] diff --git a/tests/core/test_command_runner.py b/tests/core/test_command_runner.py index 2242c1a..70c5325 100644 --- a/tests/core/test_command_runner.py +++ b/tests/core/test_command_runner.py @@ -1,8 +1,9 @@ # Released under MIT License. # Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab - -from typing import TYPE_CHECKING, Any +import time +from collections.abc import Callable +from pathlib import Path from unittest.mock import MagicMock, patch import pytest @@ -13,31 +14,74 @@ from qq_lib.core.error_handlers import handle_not_suitable_error from qq_lib.info import Informer -if TYPE_CHECKING: - from collections.abc import Callable + +def make_runner( + job_ids: tuple[str, ...] = (), + directories: tuple[Path, ...] = (), + all_jobs: bool = False, + server: str | None = None, + callback: Callable | None = None, + n_threads: int = 1, +) -> CommandRunner: + """Construct a CommandRunner with a patched batch system.""" + return CommandRunner( + job_ids=job_ids, + directories=directories, + all=all_jobs, + server=server, + callback=callback or MagicMock(), + logger=MagicMock(), + n_threads=n_threads, + ) -def test_command_runner_build_targets_from_job_ids(): - runner = CommandRunner(("111", "222"), lambda _: None, MagicMock(), n_threads=1) +def make_mock_batch_job(job_id: str = "111") -> MagicMock: + """Create a mock BatchJobInterface with a given job ID.""" + batch_job = MagicMock() + batch_job.get_id.return_value = job_id + return batch_job - with patch("qq_lib.core.command_runner.Informer") as mock_informer: - mock_informer.from_job_id.side_effect = lambda j: MagicMock( - name=f"informer_{j}" - ) + +def make_informer(job_id: str = "111") -> MagicMock: + """Create a mock Informer.""" + informer = MagicMock(spec=Informer) + informer.job_id = job_id + return informer + + +def test_command_runner_build_targets_from_job_ids_returns_correct_count(): + with patch("qq_lib.core.command_runner.BatchInterface") as mock_bi: + batch_jobs = [make_mock_batch_job("111"), make_mock_batch_job("222")] + mock_bi.from_env_var_or_guess.return_value.get_batch_jobs_from_ids.return_value = batch_jobs + runner = make_runner(job_ids=("111", "222")) targets = runner._build_targets() assert len(targets) == 2 -def test_command_runner_build_targets_from_info_files(tmp_path): +def test_command_runner_build_targets_from_job_ids_uses_from_batch_job(): + with patch("qq_lib.core.command_runner.BatchInterface") as mock_bi: + batch_job = make_mock_batch_job("111") + mock_bi.from_env_var_or_guess.return_value.get_batch_jobs_from_ids.return_value = [ + batch_job + ] + runner = make_runner(job_ids=("111",)) + + with patch("qq_lib.core.command_runner.Informer") as mock_informer: + targets = runner._build_targets() + targets[0]() + + mock_informer.from_batch_job.assert_called_once_with(batch_job) + + +def test_command_runner_build_targets_from_files_returns_correct_count(tmp_path): info1 = tmp_path / "job1.qqinfo" info2 = tmp_path / "job2.qqinfo" info1.touch() info2.touch() - runner = CommandRunner( - (), lambda _: None, MagicMock(), n_threads=1, directory=tmp_path - ) + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = make_runner(directories=(tmp_path,)) with ( patch("qq_lib.core.command_runner.get_info_files", return_value=[info1, info2]), @@ -48,64 +92,64 @@ def test_command_runner_build_targets_from_info_files(tmp_path): assert len(targets) == 2 -def test_command_runner_build_targets_raises_when_no_info_files(): - runner = CommandRunner((), lambda _: None, MagicMock(), n_threads=1) +def test_command_runner_build_targets_from_files_calls_from_file(tmp_path): + info_file = tmp_path / "job.qqinfo" + info_file.touch() + + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = make_runner(directories=(tmp_path,)) with ( - patch("qq_lib.core.command_runner.get_info_files", return_value=[]), - pytest.raises(QQError, match="No qq job info file found"), + patch("qq_lib.core.command_runner.get_info_files", return_value=[info_file]), + patch("qq_lib.core.command_runner.Informer") as mock_informer, ): - runner._build_targets() - - -def test_command_runner_build_targets_resolves_informer_from_job_id(): - runner = CommandRunner(("12345",), lambda _: None, MagicMock(), n_threads=1) - - with patch("qq_lib.core.command_runner.Informer") as mock_informer: - mock_informer.from_job_id.return_value = MagicMock() - mock_informer.from_job_id.return_value.load_batch_info = MagicMock() + mock_informer.from_file.return_value = MagicMock() targets = runner._build_targets() targets[0]() - mock_informer.from_job_id.assert_called_once_with("12345") + mock_informer.from_file.assert_called_once_with(info_file) -def test_command_runner_build_targets_resolves_informer_from_file(tmp_path): +def test_command_runner_build_targets_from_files_calls_load_batch_info(tmp_path): info_file = tmp_path / "job.qqinfo" info_file.touch() - runner = CommandRunner( - (), lambda _: None, MagicMock(), n_threads=1, directory=tmp_path - ) + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = make_runner(directories=(tmp_path,)) with ( patch("qq_lib.core.command_runner.get_info_files", return_value=[info_file]), patch("qq_lib.core.command_runner.Informer") as mock_informer, ): - mock_informer.from_file.return_value = MagicMock() - mock_informer.from_file.return_value.load_batch_info = MagicMock() + informer = MagicMock() + mock_informer.from_file.return_value = informer targets = runner._build_targets() targets[0]() - mock_informer.from_file.assert_called_once_with(info_file) + informer.load_batch_info.assert_called_once() -def test_command_runner_build_targets_calls_load_batch_info(): - runner = CommandRunner(("111",), lambda _: None, MagicMock(), n_threads=1) +def test_command_runner_build_targets_uses_cwd_when_no_jobs_or_directories(): + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = make_runner() - with patch("qq_lib.core.command_runner.Informer") as mock_informer: - informer = MagicMock() - mock_informer.from_job_id.return_value = informer - targets = runner._build_targets() - targets[0]() + with ( + patch("qq_lib.core.command_runner.get_info_files", return_value=[]) as mock_get, + pytest.raises(QQError), + ): + runner._build_targets() - informer.load_batch_info.assert_called_once() + mock_get.assert_called_once_with(Path.cwd()) -def test_command_runner_build_targets_uses_specified_directory(tmp_path): - runner = CommandRunner( - (), lambda _: None, MagicMock(), n_threads=1, directory=tmp_path - ) +def test_command_runner_build_targets_uses_specified_directories(tmp_path): + dir1 = tmp_path / "a" + dir2 = tmp_path / "b" + dir1.mkdir() + dir2.mkdir() + + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = make_runner(directories=(dir1, dir2)) with ( patch("qq_lib.core.command_runner.get_info_files", return_value=[]) as mock_get, @@ -113,36 +157,150 @@ def test_command_runner_build_targets_uses_specified_directory(tmp_path): ): runner._build_targets() - mock_get.assert_called_once_with(tmp_path) + assert mock_get.call_count == 2 + mock_get.assert_any_call(dir1) + mock_get.assert_any_call(dir2) + + +def test_command_runner_build_targets_all_queries_unfinished_jobs(): + with patch("qq_lib.core.command_runner.BatchInterface") as mock_bi: + batch_jobs = [make_mock_batch_job("111"), make_mock_batch_job("222")] + mock_bi.from_env_var_or_guess.return_value.get_unfinished_batch_jobs.return_value = batch_jobs + mock_bi.from_env_var_or_guess.return_value.get_batch_jobs_from_ids.return_value = [] + runner = make_runner(all_jobs=True) + + with patch("qq_lib.core.command_runner.Informer"): + targets = runner._build_targets() + + assert len(targets) == 2 + + +def test_command_runner_build_targets_all_and_job_ids_combined(): + with patch("qq_lib.core.command_runner.BatchInterface") as mock_bi: + id_jobs = [make_mock_batch_job("111")] + all_jobs = [make_mock_batch_job("222"), make_mock_batch_job("333")] + mock_bi.from_env_var_or_guess.return_value.get_batch_jobs_from_ids.return_value = id_jobs + mock_bi.from_env_var_or_guess.return_value.get_unfinished_batch_jobs.return_value = all_jobs + runner = make_runner(job_ids=("111",), all_jobs=True) + + with patch("qq_lib.core.command_runner.Informer"): + targets = runner._build_targets() + + assert len(targets) == 3 + + +def test_command_runner_build_targets_all_and_directories_combined(tmp_path): + info_file = tmp_path / "job.qqinfo" + info_file.touch() + + with patch("qq_lib.core.command_runner.BatchInterface") as mock_bi: + all_jobs = [make_mock_batch_job("111")] + mock_bi.from_env_var_or_guess.return_value.get_batch_jobs_from_ids.return_value = [] + mock_bi.from_env_var_or_guess.return_value.get_unfinished_batch_jobs.return_value = all_jobs + runner = make_runner(all_jobs=True, directories=(tmp_path,)) + + with ( + patch( + "qq_lib.core.command_runner.get_info_files", return_value=[info_file] + ), + patch("qq_lib.core.command_runner.Informer"), + ): + targets = runner._build_targets() + + assert len(targets) == 2 + + +def test_command_runner_build_targets_raises_when_no_info_files(): + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = make_runner() + + with ( + patch("qq_lib.core.command_runner.get_info_files", return_value=[]), + pytest.raises(QQError, match="No qq job info file found"), + ): + runner._build_targets() + + +def test_command_runner_build_targets_raises_when_no_jobs_found(): + with patch("qq_lib.core.command_runner.BatchInterface") as mock_bi: + mock_bi.from_env_var_or_guess.return_value.get_batch_jobs_from_ids.return_value = [] + runner = make_runner(job_ids=("111",)) + + with pytest.raises(QQError, match="No jobs found"): + runner._build_targets() + + +def test_command_runner_build_targets_server_ignored_without_all_logs_warning(): + with patch("qq_lib.core.command_runner.BatchInterface") as mock_bi: + mock_bi.from_env_var_or_guess.return_value.get_batch_jobs_from_ids.return_value = [ + make_mock_batch_job("111") + ] + mock_logger = MagicMock() + runner = CommandRunner( + job_ids=("111",), + directories=(), + all=False, + server="server", + callback=MagicMock(), + logger=mock_logger, + n_threads=1, + ) + + with patch("qq_lib.core.command_runner.Informer"): + runner._build_targets() + + mock_logger.warning.assert_called_once() def test_command_runner_execute_calls_callback_with_args(): - callback = MagicMock() - runner = CommandRunner( - ("111",), callback, MagicMock(), "arg1", "arg2", n_threads=1, kw="val" - ) + with patch("qq_lib.core.command_runner.BatchInterface"): + callback = MagicMock() + runner = make_runner(callback=callback) + + informer = make_informer() + runner._execute(informer) + + callback.assert_called_once_with(informer) + + +def test_command_runner_execute_calls_callback_with_extra_args(): + with patch("qq_lib.core.command_runner.BatchInterface"): + callback = MagicMock() + runner = CommandRunner( + (), + (), + False, + None, + callback, + MagicMock(), + "arg1", + "arg2", + n_threads=1, + kw="val", + ) - informer = MagicMock() + informer = make_informer() runner._execute(informer) callback.assert_called_once_with(informer, "arg1", "arg2", kw="val") -def test_command_runner_execute_passes_unregistered_exception(): - callback = MagicMock(side_effect=RuntimeError("boom")) - runner = CommandRunner(("111",), callback, MagicMock(), n_threads=1) +def test_command_runner_execute_reraises_unregistered_exception(): + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = make_runner(callback=MagicMock(side_effect=RuntimeError("boom"))) with pytest.raises(RuntimeError, match="boom"): - runner._execute(MagicMock()) + runner._execute(make_informer()) def test_command_runner_execute_handles_registered_exception(): - callback = MagicMock(side_effect=QQError("fail")) - handler = MagicMock() - runner = CommandRunner(("111",), callback, MagicMock(), n_threads=1) - runner.on_exception(QQError, handler) + with patch("qq_lib.core.command_runner.BatchInterface"): + callback = MagicMock(side_effect=QQError("fail")) + handler = MagicMock() + runner = make_runner(callback=callback) + runner.on_exception(QQError, handler) - runner._execute(MagicMock()) + runner._execute(make_informer()) handler.assert_called_once() assert isinstance(handler.call_args[0][0], QQError) @@ -150,10 +308,11 @@ def test_command_runner_execute_handles_registered_exception(): def test_command_runner_handle_error_records_in_encountered_errors(): - runner = CommandRunner(("111",), lambda _: None, MagicMock(), n_threads=1) + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = make_runner() + runner.current_iteration = 3 error = QQError("fail") - runner.on_exception(QQError, MagicMock()) runner._handle_error(error) @@ -161,18 +320,21 @@ def test_command_runner_handle_error_records_in_encountered_errors(): def test_command_runner_handle_error_calls_registered_handler(): + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = make_runner() + handler = MagicMock() - runner = CommandRunner(("111",), lambda _: None, MagicMock(), n_threads=1) runner.on_exception(QQError, handler) error = QQError("fail") - runner._handle_error(error) handler.assert_called_once_with(error, runner) def test_command_runner_handle_error_reraises_unregistered_exception(): - runner = CommandRunner(("111",), lambda _: None, MagicMock(), n_threads=1) + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = make_runner() + error = RuntimeError("unexpected") with pytest.raises(RuntimeError, match="unexpected"): @@ -182,7 +344,8 @@ def test_command_runner_handle_error_reraises_unregistered_exception(): def test_command_runner_on_exception_returns_self_for_chaining(): - runner = CommandRunner(("111",), lambda _: None, MagicMock(), n_threads=1) + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = make_runner() result = runner.on_exception(QQError, MagicMock()) @@ -190,10 +353,11 @@ def test_command_runner_on_exception_returns_self_for_chaining(): def test_command_runner_on_exception_registers_multiple_handlers(): - runner = CommandRunner(("111",), lambda _: None, MagicMock(), n_threads=1) + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = make_runner() + handler1 = MagicMock() handler2 = MagicMock() - runner.on_exception(QQError, handler1).on_exception(QQNotSuitableError, handler2) runner.current_iteration = 0 @@ -207,42 +371,37 @@ def test_command_runner_on_exception_registers_multiple_handlers(): def test_command_runner_run_pipeline_executes_callback_for_each_target(): results = [] - def callback(i, *a: Any, **kw: Any): - _ = a, kw - return results.append(i) - - runner = CommandRunner(("111", "222"), callback, MagicMock(), n_threads=1) + def callback(informer, *args, **kwargs): + _ = args, kwargs + results.append(informer) - informer1 = MagicMock(spec=Informer) - informer2 = MagicMock(spec=Informer) - targets: list[Callable[[], Informer]] = [lambda: informer1, lambda: informer2] + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = make_runner(callback=callback) - runner._run_pipeline(targets) + informer1 = make_informer("111") + informer2 = make_informer("222") + runner._run_pipeline([lambda: informer1, lambda: informer2]) assert results == [informer1, informer2] def test_command_runner_run_pipeline_preserves_order_with_multiple_threads(): - import time - execution_order = [] def slow_target(): time.sleep(0.2) - informer = MagicMock(spec=Informer) - informer.name = "slow" - return informer + return make_informer("slow") def fast_target(): - informer = MagicMock(spec=Informer) - informer.name = "fast" - return informer + return make_informer("fast") - def callback(i, *a: Any, **kw: Any): - _ = a, kw - return execution_order.append(i.name) + def callback(informer, *args, **kwargs): + _ = args, kwargs + execution_order.append(informer.job_id) + + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = make_runner(callback=callback, n_threads=2) - runner = CommandRunner(("111", "222"), callback, MagicMock(), n_threads=2) runner._run_pipeline([slow_target, fast_target]) assert execution_order == ["slow", "fast"] @@ -251,7 +410,10 @@ def callback(i, *a: Any, **kw: Any): def test_command_runner_run_pipeline_handles_preparation_failure(): handler = MagicMock() callback = MagicMock() - runner = CommandRunner(("111",), callback, MagicMock(), n_threads=1) + + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = make_runner(callback=callback) + runner.on_exception(QQError, handler) def failing_target(): @@ -267,14 +429,15 @@ def test_command_runner_run_pipeline_continues_after_preparation_failure(): results = [] handler = MagicMock() - def callback(i, *a, **kw): - _ = a, kw - return results.append(i) + def callback(informer, *args, **kwargs): + _ = args, kwargs + results.append(informer) - runner = CommandRunner(("111", "222"), callback, MagicMock(), n_threads=1) - runner.on_exception(QQError, handler) + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = make_runner(callback=callback) - informer = MagicMock(spec=Informer) + runner.on_exception(QQError, handler) + informer = make_informer() def failing_target(): raise QQError("resolve failed") @@ -288,7 +451,7 @@ def failing_target(): def test_command_runner_run_pipeline_continues_after_callback_failure(): call_count = 0 - def callback(informer: Informer, *args: Any, **kwargs: Any): + def callback(informer, *args, **kwargs): _ = informer, args, kwargs nonlocal call_count call_count += 1 @@ -296,12 +459,11 @@ def callback(informer: Informer, *args: Any, **kwargs: Any): raise QQError("callback failed") handler = MagicMock() - runner = CommandRunner(("111", "222"), callback, MagicMock(), n_threads=1) - runner.on_exception(QQError, handler) - def make_informer(): - return MagicMock(spec=Informer) + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = make_runner(callback=callback) + runner.on_exception(QQError, handler) runner._run_pipeline([make_informer, make_informer]) handler.assert_called_once() @@ -309,10 +471,9 @@ def make_informer(): def test_command_runner_run_pipeline_sets_n_jobs(): - def make_informer(): - return MagicMock(spec=Informer) + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = make_runner() - runner = CommandRunner(("1", "2", "3"), lambda _: None, MagicMock(), n_threads=1) runner._run_pipeline([make_informer, make_informer, make_informer]) assert runner.n_jobs == 3 @@ -321,15 +482,14 @@ def make_informer(): def test_command_runner_run_pipeline_tracks_current_iteration(): iterations = [] - def handler(e: Exception, r: CommandRunner): + def handler(e, r): _ = e - return iterations.append(r.current_iteration) + iterations.append(r.current_iteration) - runner = CommandRunner(("1", "2", "3"), lambda _: None, MagicMock(), n_threads=1) - runner.on_exception(QQError, handler) + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = make_runner() - def make_informer(): - return MagicMock(spec=Informer) + runner.on_exception(QQError, handler) def fail(): raise QQError("fail") @@ -341,11 +501,11 @@ def fail(): def test_command_runner_run_pipeline_records_all_errors(): handler = MagicMock() - runner = CommandRunner(("1", "2", "3"), lambda _: None, MagicMock(), n_threads=1) - runner.on_exception(QQError, handler) - def make_informer(): - return MagicMock(spec=Informer) + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = make_runner() + + runner.on_exception(QQError, handler) def fail(): raise QQError("fail") @@ -358,7 +518,8 @@ def fail(): def test_command_runner_run_pipeline_reraises_unhandled_preparation_error(): - runner = CommandRunner(("111",), lambda _: None, MagicMock(), n_threads=1) + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = make_runner() def failing_target(): raise RuntimeError("unexpected") @@ -368,103 +529,114 @@ def failing_target(): def test_command_runner_run_exits_zero_on_success(): - def make_informer(): - return MagicMock(spec=Informer) - - runner = CommandRunner(("111",), lambda _: None, MagicMock(), n_threads=1) + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = make_runner() with ( patch.object(runner, "_build_targets", return_value=[make_informer]), patch.object(runner, "_run_pipeline"), + pytest.raises(SystemExit) as exc_info, ): - try: - runner.run() - assert False, "Expected SystemExit" - except SystemExit as e: - assert e.code == 0 + runner.run() + + assert exc_info.value.code == 0 def test_command_runner_run_exits_default_on_qq_error(): mock_logger = MagicMock() - runner = CommandRunner(("111",), lambda _: None, mock_logger, n_threads=1) - with patch.object(runner, "_build_targets", side_effect=QQError("fail")): - try: - runner.run() - assert False, "Expected SystemExit" - except SystemExit as e: - assert e.code == CFG.exit_codes.default + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = CommandRunner( + job_ids=(), + directories=(), + all=False, + server=None, + callback=MagicMock(), + logger=mock_logger, + n_threads=1, + ) + + with ( + patch.object(runner, "_build_targets", side_effect=QQError("fail")), + pytest.raises(SystemExit) as exc_info, + ): + runner.run() + assert exc_info.value.code == CFG.exit_codes.default mock_logger.error.assert_called_once() def test_command_runner_run_exits_unexpected_on_generic_exception(): mock_logger = MagicMock() - runner = CommandRunner(("111",), lambda _: None, mock_logger, n_threads=1) - with patch.object(runner, "_build_targets", side_effect=RuntimeError("boom")): - try: - runner.run() - assert False, "Expected SystemExit" - except SystemExit as e: - assert e.code == CFG.exit_codes.unexpected_error + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = CommandRunner( + job_ids=(), + directories=(), + all=False, + server=None, + callback=MagicMock(), + logger=mock_logger, + n_threads=1, + ) + with ( + patch.object(runner, "_build_targets", side_effect=RuntimeError("boom")), + pytest.raises(SystemExit) as exc_info, + ): + runner.run() + + assert exc_info.value.code == CFG.exit_codes.unexpected_error mock_logger.critical.assert_called_once() -def test_command_runnerrun_with_not_suitable_handler_single_job(): +def test_command_runner_run_with_not_suitable_handler_single_job(): callback = MagicMock(side_effect=QQNotSuitableError("not suitable")) - mock_logger = MagicMock() - def make_informer(): - return MagicMock(spec=Informer) + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = make_runner(callback=callback) - runner = CommandRunner(("111",), callback, mock_logger, n_threads=1) runner.on_exception(QQNotSuitableError, handle_not_suitable_error) with ( patch.object(runner, "_build_targets", return_value=[make_informer]), + pytest.raises(SystemExit) as exc_info, ): - try: - runner.run() - assert False, "Expected SystemExit" - except SystemExit as e: - assert e.code == CFG.exit_codes.default + runner.run() + assert exc_info.value.code == CFG.exit_codes.default -def test_command_runner_run_preserves_order_end_to_end(): - import time +def test_command_runner_run_preserves_order_end_to_end(): execution_order = [] def callback(informer, *args, **kwargs): _ = args, kwargs execution_order.append(informer.job_id) - runner = CommandRunner(("111", "222", "333"), callback, MagicMock(), n_threads=3) + with patch("qq_lib.core.command_runner.BatchInterface"): + runner = make_runner(callback=callback, n_threads=3) def make_target(job_id, delay): def target(): time.sleep(delay) - informer = MagicMock(spec=Informer) - informer.job_id = job_id - return informer + return make_informer(job_id) return target - with patch.object( - runner, - "_build_targets", - return_value=[ - make_target("111", 0.2), - make_target("222", 0.1), - make_target("333", 0.0), - ], + with ( + patch.object( + runner, + "_build_targets", + return_value=[ + make_target("111", 0.2), + make_target("222", 0.1), + make_target("333", 0.0), + ], + ), + pytest.raises(SystemExit) as exc_info, ): - try: - runner.run() - assert False, "Expected SystemExit" - except SystemExit as e: - assert e.code == 0 + runner.run() + assert exc_info.value.code == 0 assert execution_order == ["111", "222", "333"] diff --git a/tests/go/test_go_cli.py b/tests/go/test_go_cli.py index 4eb0376..f3842ae 100644 --- a/tests/go/test_go_cli.py +++ b/tests/go/test_go_cli.py @@ -42,6 +42,64 @@ def test_go_creates_command_runner_and_runs(): assert result.exit_code == 0 mock_cls.assert_called_once_with( ("111", "222"), + (), + False, + None, + _go_to_job, + logger, + n_threads=CFG.parallelization_options.job_info_max_threads, + ) + mock_cls.return_value.run.assert_called_once() + + +def test_go_creates_command_runner_with_dirs_and_runs(tmp_path): + dir1 = tmp_path / "dir1" + dir2 = tmp_path / "dir2" + dir1.mkdir() + dir2.mkdir() + runner = CliRunner() + + with patch("qq_lib.go.cli.CommandRunner") as mock_cls: + mock_cls.return_value.on_exception.return_value = mock_cls.return_value + mock_cls.return_value.run.side_effect = SystemExit(0) + + result = runner.invoke(go, ["-d", str(dir1), str(dir2)]) + + assert result.exit_code == 0 + mock_cls.assert_called_once_with( + (), + (dir1, dir2), + False, + None, + _go_to_job, + logger, + n_threads=CFG.parallelization_options.job_info_max_threads, + ) + mock_cls.return_value.run.assert_called_once() + + +def test_go_creates_complex_command_runner_and_runs(tmp_path): + dir1 = tmp_path / "dir1" + dir2 = tmp_path / "dir2" + dir1.mkdir() + dir2.mkdir() + runner = CliRunner() + + with patch("qq_lib.go.cli.CommandRunner") as mock_cls: + mock_cls.return_value.on_exception.return_value = mock_cls.return_value + mock_cls.return_value.run.side_effect = SystemExit(0) + + result = runner.invoke( + go, + ["12345", "12346", "-d", str(dir1), str(dir2), "--all", "-s", "server"], + ) + + assert result.exit_code == 0 + mock_cls.assert_called_once_with( + ("12345", "12346"), + (dir1, dir2), + True, + "server", _go_to_job, logger, n_threads=CFG.parallelization_options.job_info_max_threads, diff --git a/tests/info/test_info_cli.py b/tests/info/test_info_cli.py index 32d56f5..92c5ac8 100644 --- a/tests/info/test_info_cli.py +++ b/tests/info/test_info_cli.py @@ -66,6 +66,66 @@ def test_info_creates_command_runner_and_runs(): assert result.exit_code == 0 mock_cls.assert_called_once_with( ("111",), + (), + False, + None, + _info_for_job, + logger, + False, + n_threads=CFG.parallelization_options.job_info_max_threads, + ) + mock_cls.return_value.run.assert_called_once() + + +def test_info_creates_command_runner_with_dirs_and_runs(tmp_path): + dir1 = tmp_path / "dir1" + dir2 = tmp_path / "dir2" + dir1.mkdir() + dir2.mkdir() + runner = CliRunner() + + with patch("qq_lib.info.cli.CommandRunner") as mock_cls: + mock_cls.return_value.on_exception.return_value = mock_cls.return_value + mock_cls.return_value.run.side_effect = SystemExit(0) + + result = runner.invoke(info, ["-d", str(dir1), str(dir2)]) + + assert result.exit_code == 0 + mock_cls.assert_called_once_with( + (), + (dir1, dir2), + False, + None, + _info_for_job, + logger, + False, + n_threads=CFG.parallelization_options.job_info_max_threads, + ) + mock_cls.return_value.run.assert_called_once() + + +def test_info_creates_complex_command_runner_and_runs(tmp_path): + dir1 = tmp_path / "dir1" + dir2 = tmp_path / "dir2" + dir1.mkdir() + dir2.mkdir() + runner = CliRunner() + + with patch("qq_lib.info.cli.CommandRunner") as mock_cls: + mock_cls.return_value.on_exception.return_value = mock_cls.return_value + mock_cls.return_value.run.side_effect = SystemExit(0) + + result = runner.invoke( + info, + ["12345", "12346", "-d", str(dir1), str(dir2), "--all", "-s", "server"], + ) + + assert result.exit_code == 0 + mock_cls.assert_called_once_with( + ("12345", "12346"), + (dir1, dir2), + True, + "server", _info_for_job, logger, False, @@ -83,7 +143,7 @@ def test_info_passes_short_flag(): runner.invoke(info, ["--short", "111"]) - assert mock_cls.call_args[0][3] is True + assert mock_cls.call_args[0][-1] is True def test_info_registers_exception_handlers(): diff --git a/tests/info/test_info_informer.py b/tests/info/test_info_informer.py index c03202b..fe5c4ce 100644 --- a/tests/info/test_info_informer.py +++ b/tests/info/test_info_informer.py @@ -702,3 +702,82 @@ def test_informer_should_archive_files_with_transfer_modes_list(): assert informer.should_archive_files(1) is True assert informer.should_archive_files(42) is True assert informer.should_archive_files(0) is False + + +def make_mock_informer(job_id: str = "111") -> MagicMock: + """Create a mock Informer with a given job ID and a mock batch system.""" + informer = MagicMock(spec=Informer) + informer.info = MagicMock() + informer.info.job_id = job_id + informer.batch_system = MagicMock() + return informer + + +def test_informer_set_batch_info_in_bulk_does_not_query_batch_system_for_empty_list(): + mock_batch_system = MagicMock() + Informer.set_batch_info_in_bulk([]) + mock_batch_system.get_batch_jobs_from_ids.assert_not_called() + + +def test_informer_set_batch_info_in_bulk_queries_correct_job_ids(): + informers = [make_mock_informer("111"), make_mock_informer("222")] + mock_batch_jobs = [MagicMock(), MagicMock()] + informers[0].batch_system.get_batch_jobs_from_ids.return_value = mock_batch_jobs + + Informer.set_batch_info_in_bulk(informers) # type: ignore + + informers[0].batch_system.get_batch_jobs_from_ids.assert_called_once_with( + ["111", "222"] + ) + + +def test_informer_set_batch_info_in_bulk_uses_first_informers_batch_system(): + informers = [make_mock_informer("111"), make_mock_informer("222")] + mock_batch_jobs = [MagicMock(), MagicMock()] + informers[0].batch_system.get_batch_jobs_from_ids.return_value = mock_batch_jobs + + Informer.set_batch_info_in_bulk(informers) # type: ignore + + informers[0].batch_system.get_batch_jobs_from_ids.assert_called_once() + informers[1].batch_system.get_batch_jobs_from_ids.assert_not_called() + + +def test_informer_set_batch_info_in_bulk_sets_batch_info_on_each_informer(): + informers = [make_mock_informer("111"), make_mock_informer("222")] + batch_job_1 = MagicMock() + batch_job_2 = MagicMock() + informers[0].batch_system.get_batch_jobs_from_ids.return_value = [ + batch_job_1, + batch_job_2, + ] + + Informer.set_batch_info_in_bulk(informers) # type: ignore + + assert informers[0]._batch_info is batch_job_1 + assert informers[1]._batch_info is batch_job_2 + + +def test_informer_set_batch_info_in_bulk_preserves_order(): + informers = [ + make_mock_informer("111"), + make_mock_informer("222"), + make_mock_informer("333"), + ] + batch_jobs = [MagicMock(), MagicMock(), MagicMock()] + informers[0].batch_system.get_batch_jobs_from_ids.return_value = batch_jobs + + Informer.set_batch_info_in_bulk(informers) # type: ignore + + for informer, batch_job in zip(informers, batch_jobs): + assert informer._batch_info is batch_job + + +def test_informer_set_batch_info_in_bulk_single_informer(): + informer = make_mock_informer("111") + batch_job = MagicMock() + informer.batch_system.get_batch_jobs_from_ids.return_value = [batch_job] + + Informer.set_batch_info_in_bulk([informer]) + + informer.batch_system.get_batch_jobs_from_ids.assert_called_once_with(["111"]) + assert informer._batch_info is batch_job diff --git a/tests/kill/test_kill_cli.py b/tests/kill/test_kill_cli.py index 2e0e3e6..fee580b 100644 --- a/tests/kill/test_kill_cli.py +++ b/tests/kill/test_kill_cli.py @@ -80,6 +80,68 @@ def test_kill_creates_command_runner_and_runs(): assert result.exit_code == 0 mock_cls.assert_called_once_with( ("111",), + (), + False, + None, + kill_job, + logger, + False, + False, + n_threads=CFG.parallelization_options.job_info_max_threads, + ) + mock_cls.return_value.run.assert_called_once() + + +def test_kill_creates_command_runner_with_dirs_and_runs(tmp_path): + dir1 = tmp_path / "dir1" + dir2 = tmp_path / "dir2" + dir1.mkdir() + dir2.mkdir() + runner = CliRunner() + + with patch("qq_lib.kill.cli.CommandRunner") as mock_cls: + mock_cls.return_value.on_exception.return_value = mock_cls.return_value + mock_cls.return_value.run.side_effect = SystemExit(0) + + result = runner.invoke(kill, ["-d", str(dir1), str(dir2)]) + + assert result.exit_code == 0 + mock_cls.assert_called_once_with( + (), + (dir1, dir2), + False, + None, + kill_job, + logger, + False, + False, + n_threads=CFG.parallelization_options.job_info_max_threads, + ) + mock_cls.return_value.run.assert_called_once() + + +def test_kill_creates_complex_command_runner_and_runs(tmp_path): + dir1 = tmp_path / "dir1" + dir2 = tmp_path / "dir2" + dir1.mkdir() + dir2.mkdir() + runner = CliRunner() + + with patch("qq_lib.kill.cli.CommandRunner") as mock_cls: + mock_cls.return_value.on_exception.return_value = mock_cls.return_value + mock_cls.return_value.run.side_effect = SystemExit(0) + + result = runner.invoke( + kill, + ["12345", "12346", "-d", str(dir1), str(dir2), "--all", "-s", "server"], + ) + + assert result.exit_code == 0 + mock_cls.assert_called_once_with( + ("12345", "12346"), + (dir1, dir2), + True, + "server", kill_job, logger, False, @@ -98,9 +160,9 @@ def test_kill_passes_force_and_yes_flags(): runner.invoke(kill, ["--force", "--yes", "111"]) - # force is arg index 3, yes is arg index 4 - assert mock_cls.call_args[0][3] is True - assert mock_cls.call_args[0][4] is True + # force is arg index 6, yes is arg index 7 + assert mock_cls.call_args[0][6] is True + assert mock_cls.call_args[0][7] is True def test_kill_registers_exception_handlers(): diff --git a/tests/killall/test_killall_cli.py b/tests/killall/test_killall_cli.py deleted file mode 100644 index 0e9ecc1..0000000 --- a/tests/killall/test_killall_cli.py +++ /dev/null @@ -1,302 +0,0 @@ -# Released under MIT License. -# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab - - -import getpass -from unittest.mock import MagicMock, patch - -from click.testing import CliRunner - -from qq_lib.batch.interface.job import BatchJobInterface -from qq_lib.core.config import CFG -from qq_lib.core.error import QQError, QQNotSuitableError -from qq_lib.kill.cli import kill_job -from qq_lib.killall.cli import ( - _informers_from_jobs, - _log_error_and_continue, - killall, -) - - -def test_informers_from_jobs(): - job_good = MagicMock() - job_bad = MagicMock() - job_good2 = MagicMock() - - informer1 = MagicMock() - informer2 = MagicMock() - - with patch( - "qq_lib.killall.cli.Informer.from_batch_job", - side_effect=[informer1, QQError(), informer2], - ): - result = _informers_from_jobs([job_good, job_bad, job_good2]) - - assert result == [informer1, informer2] - - -def test_informers_from_jobs_no_jobs(): - result = _informers_from_jobs([]) - assert result == [] - - -def test_killall_no_jobs_exits_zero(): - runner = CliRunner() - with ( - patch( - "qq_lib.killall.cli.BatchInterface.from_env_var_or_guess" - ) as batch_meta_mock, - patch("qq_lib.killall.cli.logger") as logger_mock, - ): - batch_system = batch_meta_mock.return_value - batch_system.get_unfinished_batch_jobs.return_value = [] - - result = runner.invoke(killall) - - batch_system.get_unfinished_batch_jobs.assert_called_once_with( - getpass.getuser(), None - ) - logger_mock.info.assert_called_once_with( - "You have no active jobs. Nothing to kill." - ) - assert result.exit_code == 0 - - -def test_killall_jobs_but_no_info_files_exits_zero(): - runner = CliRunner() - job_mock = MagicMock(spec=BatchJobInterface) - - with ( - patch( - "qq_lib.killall.cli.BatchInterface.from_env_var_or_guess" - ) as batch_meta_mock, - patch("qq_lib.killall.cli._informers_from_jobs", return_value=[]), - patch("qq_lib.killall.cli.logger") as logger_mock, - ): - batch_system = batch_meta_mock.return_value - batch_system.get_unfinished_batch_jobs.return_value = [job_mock] - - result = runner.invoke(killall) - - batch_system.get_unfinished_batch_jobs.assert_called_once_with( - getpass.getuser(), None - ) - logger_mock.info.assert_called_once_with( - "You have no active qq jobs (and 1 other jobs). Nothing to kill." - ) - assert result.exit_code == 0 - - -def test_killall_yes_flag_invokes_repeater(): - informer_mock = MagicMock() - runner = CliRunner() - repeater_mock = MagicMock() - - job_mock = MagicMock(spec=BatchJobInterface) - - with ( - patch( - "qq_lib.killall.cli.BatchInterface.from_env_var_or_guess" - ) as batch_meta_mock, - patch("qq_lib.killall.cli._informers_from_jobs", return_value=[informer_mock]), - patch( - "qq_lib.killall.cli.Repeater", return_value=repeater_mock - ) as repeater_cls, - patch("qq_lib.killall.cli.logger"), - ): - batch_system = batch_meta_mock.return_value - batch_system.get_unfinished_batch_jobs.return_value = [job_mock] - - result = runner.invoke(killall, ["--yes"]) - - batch_system.get_unfinished_batch_jobs.assert_called_once_with( - getpass.getuser(), None - ) - repeater_mock.on_exception.assert_any_call( - QQNotSuitableError, _log_error_and_continue - ) - repeater_mock.on_exception.assert_any_call(QQError, _log_error_and_continue) - repeater_cls.assert_called_once_with( - [informer_mock], - kill_job, - force=False, - yes=True, - ) - repeater_mock.run.assert_called_once() - assert result.exit_code == 0 - - -def test_killall_force_flag_invokes_repeater(): - informer_mock = MagicMock() - runner = CliRunner() - repeater_mock = MagicMock() - - job_mock = MagicMock(spec=BatchJobInterface) - - with ( - patch( - "qq_lib.killall.cli.BatchInterface.from_env_var_or_guess" - ) as batch_meta_mock, - patch("qq_lib.killall.cli._informers_from_jobs", return_value=[informer_mock]), - patch( - "qq_lib.killall.cli.Repeater", return_value=repeater_mock - ) as repeater_cls, - patch("qq_lib.killall.cli.logger"), - ): - batch_system = batch_meta_mock.return_value - batch_system.get_unfinished_batch_jobs.return_value = [job_mock] - - result = runner.invoke(killall, ["--force"]) - - batch_system.get_unfinished_batch_jobs.assert_called_once_with( - getpass.getuser(), None - ) - repeater_cls.assert_called_once_with( - [informer_mock], - kill_job, - force=True, - yes=True, - ) - repeater_mock.run.assert_called_once() - assert result.exit_code == 0 - - -def test_killall_user_prompt_yes(monkeypatch): - informer_mock = MagicMock() - runner = CliRunner() - repeater_mock = MagicMock() - - monkeypatch.setattr("qq_lib.killall.cli.yes_or_no_prompt", lambda _msg: True) - - job_mock = MagicMock(spec=BatchJobInterface) - - with ( - patch( - "qq_lib.killall.cli.BatchInterface.from_env_var_or_guess" - ) as batch_meta_mock, - patch("qq_lib.killall.cli._informers_from_jobs", return_value=[informer_mock]), - patch( - "qq_lib.killall.cli.Repeater", return_value=repeater_mock - ) as repeater_cls, - patch("qq_lib.killall.cli.logger"), - ): - batch_system = batch_meta_mock.return_value - batch_system.get_unfinished_batch_jobs.return_value = [job_mock] - - result = runner.invoke(killall) - - batch_system.get_unfinished_batch_jobs.assert_called_once_with( - getpass.getuser(), None - ) - repeater_cls.assert_called_once_with( - [informer_mock], - kill_job, - force=False, - yes=True, - ) - repeater_mock.run.assert_called_once() - assert result.exit_code == 0 - - -def test_killall_user_prompt_no(monkeypatch): - informer_mock = MagicMock() - runner = CliRunner() - - monkeypatch.setattr("qq_lib.killall.cli.yes_or_no_prompt", lambda _msg: False) - - job_mock = MagicMock(spec=BatchJobInterface) - - with ( - patch( - "qq_lib.killall.cli.BatchInterface.from_env_var_or_guess" - ) as batch_meta_mock, - patch("qq_lib.killall.cli._informers_from_jobs", return_value=[informer_mock]), - patch("qq_lib.killall.cli.logger") as logger_mock, - ): - batch_system = batch_meta_mock.return_value - batch_system.get_unfinished_batch_jobs.return_value = [job_mock] - - result = runner.invoke(killall) - - batch_system.get_unfinished_batch_jobs.assert_called_once_with( - getpass.getuser(), None - ) - logger_mock.info.assert_called_with("Operation aborted.") - assert result.exit_code == 0 - - -def test_killall_with_full_server_name_forwards_server(): - runner = CliRunner() - with ( - patch( - "qq_lib.killall.cli.BatchInterface.from_env_var_or_guess" - ) as batch_meta_mock, - patch("qq_lib.killall.cli.logger"), - ): - batch_system = batch_meta_mock.return_value - - runner.invoke(killall, args=["--server", "fake.server.org"]) - - batch_system.get_unfinished_batch_jobs.assert_called_once_with( - getpass.getuser(), "fake.server.org" - ) - - -def test_killall_with_server_shortcut_translates_and_forwards_server(): - runner = CliRunner() - with ( - patch( - "qq_lib.killall.cli.BatchInterface.from_env_var_or_guess" - ) as batch_meta_mock, - patch("qq_lib.killall.cli.logger"), - ): - batch_system = batch_meta_mock.return_value - - runner.invoke(killall, args=["--server", "sokar"]) - - batch_system.get_unfinished_batch_jobs.assert_called_once_with( - getpass.getuser(), "sokar-pbs.ncbr.muni.cz" - ) - - -def test_killall_qqerror_in_main_loop_exits_91(): - informer_mock = MagicMock() - runner = CliRunner() - - with ( - patch( - "qq_lib.killall.cli.BatchInterface.from_env_var_or_guess" - ) as batch_meta_mock, - patch("qq_lib.killall.cli._informers_from_jobs", return_value=[informer_mock]), - patch("qq_lib.killall.cli.Repeater", side_effect=QQError("fail")), - patch("qq_lib.killall.cli.logger"), - ): - batch_system = batch_meta_mock.return_value - batch_system.get_unfinished_batch_jobs.return_value = [MagicMock()] - - result = runner.invoke(killall, ["--yes"]) - - assert result.exit_code == CFG.exit_codes.default - - -def test_killall_generic_exception_exits_99(): - informer_mock = MagicMock() - runner = CliRunner() - - def raise_exception(*_args, **_kwargs): - raise RuntimeError("unexpected") - - with ( - patch( - "qq_lib.killall.cli.BatchInterface.from_env_var_or_guess" - ) as batch_meta_mock, - patch("qq_lib.killall.cli._informers_from_jobs", return_value=[informer_mock]), - patch("qq_lib.killall.cli.Repeater", side_effect=raise_exception), - patch("qq_lib.killall.cli.logger"), - ): - batch_system = batch_meta_mock.return_value - batch_system.get_unfinished_batch_jobs.return_value = [MagicMock()] - - result = runner.invoke(killall, ["--yes"]) - - assert result.exit_code == CFG.exit_codes.unexpected_error diff --git a/tests/respawn/test_respawn_cli.py b/tests/respawn/test_respawn_cli.py index f9962c5..1f0399a 100644 --- a/tests/respawn/test_respawn_cli.py +++ b/tests/respawn/test_respawn_cli.py @@ -127,6 +127,35 @@ def test_respawn_creates_command_runner_and_runs(): assert result.exit_code == 0 mock_cls.assert_called_once_with( ("111",), + (), + False, + None, + _respawn_job, + logger, + n_threads=CFG.parallelization_options.job_info_max_threads, + ) + mock_cls.return_value.run.assert_called_once() + + +def test_respawn_creates_command_runner_with_dirs_and_runs(tmp_path): + dir1 = tmp_path / "dir1" + dir2 = tmp_path / "dir2" + dir1.mkdir() + dir2.mkdir() + runner = CliRunner() + + with patch("qq_lib.respawn.cli.CommandRunner") as mock_cls: + mock_cls.return_value.on_exception.return_value = mock_cls.return_value + mock_cls.return_value.run.side_effect = SystemExit(0) + + result = runner.invoke(respawn, ["-d", str(dir1), str(dir2)]) + + assert result.exit_code == 0 + mock_cls.assert_called_once_with( + (), + (dir1, dir2), + False, + None, _respawn_job, logger, n_threads=CFG.parallelization_options.job_info_max_threads, diff --git a/tests/sync/test_sync_cli.py b/tests/sync/test_sync_cli.py index 28e7a40..df09633 100644 --- a/tests/sync/test_sync_cli.py +++ b/tests/sync/test_sync_cli.py @@ -78,6 +78,9 @@ def test_sync_creates_command_runner_and_runs(): assert result.exit_code == 0 mock_cls.assert_called_once_with( ("111",), + (), + False, + None, _sync_job, logger, ["a.txt", "b.txt"], @@ -86,6 +89,63 @@ def test_sync_creates_command_runner_and_runs(): mock_cls.return_value.run.assert_called_once() +def test_sync_creates_command_runner_with_dirs_and_runs(tmp_path): + dir1 = tmp_path / "dir1" + dir2 = tmp_path / "dir2" + dir1.mkdir() + dir2.mkdir() + runner = CliRunner() + + with patch("qq_lib.sync.cli.CommandRunner") as mock_cls: + mock_cls.return_value.on_exception.return_value = mock_cls.return_value + mock_cls.return_value.run.side_effect = SystemExit(0) + + result = runner.invoke(sync, ["-d", str(dir1), str(dir2)]) + + assert result.exit_code == 0 + mock_cls.assert_called_once_with( + (), + (dir1, dir2), + False, + None, + _sync_job, + logger, + None, + n_threads=CFG.parallelization_options.job_info_max_threads, + ) + mock_cls.return_value.run.assert_called_once() + + +def test_sync_creates_complex_command_runner_and_runs(tmp_path): + dir1 = tmp_path / "dir1" + dir2 = tmp_path / "dir2" + dir1.mkdir() + dir2.mkdir() + runner = CliRunner() + + with patch("qq_lib.sync.cli.CommandRunner") as mock_cls: + mock_cls.return_value.on_exception.return_value = mock_cls.return_value + mock_cls.return_value.run.side_effect = SystemExit(0) + + result = runner.invoke( + sync, + ["12345", "12346", "-d", str(dir1), str(dir2), "--all", "-s", "server"], + ) + + assert result.exit_code == 0 + mock_cls.assert_called_once_with( + ("12345", "12346"), + (dir1, dir2), + True, + "server", + _sync_job, + logger, + None, + n_threads=CFG.parallelization_options.job_info_max_threads, + ) + mock_cls.return_value.run.assert_called_once() + + def test_sync_without_files_passes_none(): runner = CliRunner() diff --git a/tests/wipe/test_wipe_cli.py b/tests/wipe/test_wipe_cli.py index 3cf3484..715c205 100644 --- a/tests/wipe/test_wipe_cli.py +++ b/tests/wipe/test_wipe_cli.py @@ -106,6 +106,37 @@ def test_wipe_creates_command_runner_and_runs(): assert result.exit_code == 0 mock_cls.assert_called_once_with( ("111",), + (), + False, + None, + _wipe_work_dir, + logger, + False, + False, + n_threads=CFG.parallelization_options.job_info_max_threads, + ) + mock_cls.return_value.run.assert_called_once() + + +def test_wipe_creates_command_runner_with_dirs_and_runs(tmp_path): + dir1 = tmp_path / "dir1" + dir2 = tmp_path / "dir2" + dir1.mkdir() + dir2.mkdir() + runner = CliRunner() + + with patch("qq_lib.wipe.cli.CommandRunner") as mock_cls: + mock_cls.return_value.on_exception.return_value = mock_cls.return_value + mock_cls.return_value.run.side_effect = SystemExit(0) + + result = runner.invoke(wipe, ["-d", str(dir1), str(dir2)]) + + assert result.exit_code == 0 + mock_cls.assert_called_once_with( + (), + (dir1, dir2), + False, + None, _wipe_work_dir, logger, False, @@ -124,8 +155,8 @@ def test_wipe_passes_force_and_yes_flags(): runner.invoke(wipe, ["--force", "--yes", "111"]) - assert mock_cls.call_args[0][3] is True - assert mock_cls.call_args[0][4] is True + assert mock_cls.call_args[0][6] is True + assert mock_cls.call_args[0][7] is True def test_wipe_registers_exception_handlers(): From 10aa1482904a76af1289699cb58abf7deb7d951d Mon Sep 17 00:00:00 2001 From: Ladme Date: Sun, 7 Jun 2026 13:44:35 +0200 Subject: [PATCH 06/20] Submitting multiple jobs in parallel --- CHANGELOG.md | 6 +- src/qq_lib/core/config.py | 3 + src/qq_lib/go/cli.py | 2 +- src/qq_lib/info/cli.py | 18 +- src/qq_lib/kill/cli.py | 2 +- src/qq_lib/respawn/cli.py | 2 +- src/qq_lib/submit/cli.py | 58 +++++-- src/qq_lib/sync/cli.py | 2 +- src/qq_lib/wipe/cli.py | 2 +- tests/info/test_info_cli.py | 4 +- tests/submit/test_submit_cli.py | 294 +++++++++++++++++++++++--------- 11 files changed, 287 insertions(+), 106 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac5b5dd..d11dab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,12 @@ ## Version 0.12.0 ### Job collections -- `qq info`, `qq kill`, `qq sync`, `qq respawn`, `qq wipe`, and `qq go` can now accept one or more directories and operate on jobs in these directories. Job resolution is parallelized and thus much faster than when these commands are called individually. Some of these commands also accept the `--all` flag to operate on all unfinished jobs on the given batch server. -- **BREAKING CHANGE**: The `-s` option of `qq info` now corresponds to the `--server` option, not to the `--short` flag for consistency with other commands. +- **BREAKING CHANGE**: The `-s` option of `qq info` now corresponds to the `--server` option, not to the `--short` flag for consistency with other commands. Instead, use `--brief`/`-b` or the legacy long variant `--short` to get a brief summary of the job. - **BREAKING CHANGE**: `qq killall` command has been deprecated in favor of `qq kill --all` which has a very similar behavior. +- `qq info`, `qq kill`, `qq sync`, `qq respawn`, `qq wipe`, and `qq go` can now accept one or more directories and operate on jobs in these directories. Job resolution is parallelized and thus much faster than when these commands are called individually. Some of these commands also accept the `--all` flag to operate on all unfinished jobs on the given batch server. +- `qq submit` now supports submitting multiple scripts at once. The submission is parallelized and thus much faster than when submitting scripts one by one. + ### Other changes - If a loop job does not create any archive files for the next cycle of the loop job, qq creates an empty `.init` file fulfilling the loop job's requirements. - If a loop job-specific or continuous job-specific option is used either on the command line or inside the submitted script, a warning is printed so that the user knows that the option will be ignored. diff --git a/src/qq_lib/core/config.py b/src/qq_lib/core/config.py index 98bac9c..28fff7a 100644 --- a/src/qq_lib/core/config.py +++ b/src/qq_lib/core/config.py @@ -470,6 +470,9 @@ class ParallelizationOptions: # Maximal number of threads used to collect job information. job_info_max_threads: int = 8 + # Maximal number of threads used to submit jobs in parallel. + submission_max_threads: int = 8 + @dataclass(frozen=True) class Config: diff --git a/src/qq_lib/go/cli.py b/src/qq_lib/go/cli.py index 1f460f1..cb55052 100644 --- a/src/qq_lib/go/cli.py +++ b/src/qq_lib/go/cli.py @@ -31,7 +31,7 @@ help=f"""Open a new shell in the working directory of the specified qq job, or in the working directories of qq jobs found in the specified directories. -{click.style("JOB_ID", fg="green")} One or more IDs of jobs whose working directories should be entered. Optional. +{click.style("JOB_ID...", fg="green")} One or more IDs of jobs whose working directories should be entered. Optional. If no JOB_ID and no directory are specified, `{CFG.binary_name} go` searches for qq jobs in the current directory. If multiple suitable jobs are provided or found, `{CFG.binary_name} go` opens a shell for each job in turn. diff --git a/src/qq_lib/info/cli.py b/src/qq_lib/info/cli.py index 6879572..f7c366a 100644 --- a/src/qq_lib/info/cli.py +++ b/src/qq_lib/info/cli.py @@ -30,7 +30,7 @@ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: raise click.UsageError( "'-s' now means '--server', not '--short'. " "Use '-s ' to specify a batch server. " - "Or '--short' to display only the job ID and current state." + "Or '--short'/'--brief'/'-b' to display only the job ID and current state." ) return super().parse_args(ctx, args) @@ -39,7 +39,7 @@ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: short_help="Display information about a job.", help=f"""Display information about the state and properties of the specified qq jobs or of qq jobs found in the specified directories. -{click.style("JOB_ID", fg="green")} One or more IDs of jobs to display information for. Optional. +{click.style("JOB_ID...", fg="green")} One or more IDs of jobs to display information for. Optional. If no JOB_ID and no directory are specified, `{CFG.binary_name} info` searches for qq jobs in the current directory. @@ -73,14 +73,18 @@ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: help="Collect jobs from the specified batch server. If not specified, the current server is used. Only used with --all.", ) @click.option( - "--short", is_flag=True, help="Display only the job ID and current state." + "-b", + "--brief", + "--short", + is_flag=True, + help="Display a brief summary of the job.", ) def info( jobs: tuple[str, ...], dir: tuple[Path, ...], all: bool, server: str | None, - short: bool, + brief: bool, ) -> NoReturn: """ Get information about the specified qq jobs or qq jobs submitted from this directory. @@ -92,12 +96,12 @@ def info( server, _info_for_job, logger, - short, + brief, n_threads=CFG.parallelization_options.job_info_max_threads, ).on_exception(QQError, handle_general_qq_error).run() -def _info_for_job(informer: Informer, short: bool) -> None: +def _info_for_job(informer: Informer, brief: bool) -> None: """ Display information about a qq job associated with the specified Informer. @@ -108,7 +112,7 @@ def _info_for_job(informer: Informer, short: bool) -> None: """ presenter = Presenter(informer) console = Console() - if short: + if brief: console.print(presenter.get_short_info()) else: panel = presenter.create_full_info_panel(console) diff --git a/src/qq_lib/kill/cli.py b/src/qq_lib/kill/cli.py index ee9d769..f793423 100644 --- a/src/qq_lib/kill/cli.py +++ b/src/qq_lib/kill/cli.py @@ -33,7 +33,7 @@ short_help="Terminate a job.", help=f"""Terminate the specified qq jobs or all qq jobs in the specified directories. -{click.style("JOB_ID", fg="green")} One or more IDs of jobs to terminate. Optional. +{click.style("JOB_ID...", fg="green")} One or more IDs of jobs to terminate. Optional. If no JOB_ID and no directory are specified, `{CFG.binary_name} kill` searches for qq jobs in the current directory. diff --git a/src/qq_lib/respawn/cli.py b/src/qq_lib/respawn/cli.py index 6f171c5..f0d69ce 100644 --- a/src/qq_lib/respawn/cli.py +++ b/src/qq_lib/respawn/cli.py @@ -27,7 +27,7 @@ short_help="Respawn a failed/killed job.", help=f"""Respawn the specified qq jobs, or all qq jobs in the specified directories. -{click.style("JOB_ID", fg="green")} One or more IDs of jobs to respawn. Optional. +{click.style("JOB_ID...", fg="green")} One or more IDs of jobs to respawn. Optional. If no JOB_ID and no directory are specified, `{CFG.binary_name} respawn` searches for qq jobs in the current directory. diff --git a/src/qq_lib/submit/cli.py b/src/qq_lib/submit/cli.py index dc07d27..9be9588 100644 --- a/src/qq_lib/submit/cli.py +++ b/src/qq_lib/submit/cli.py @@ -1,10 +1,10 @@ # Released under MIT License. # Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab - import sys +from concurrent.futures import ThreadPoolExecutor from pathlib import Path -from typing import NoReturn +from typing import Any, NoReturn import click from click.shell_completion import CompletionItem @@ -56,22 +56,26 @@ def complete_script( @click.command( short_help="Submit a job to the batch system.", help=f""" -Submit a qq job to a batch system from the command line. +Submit one or more qq jobs to the batch system from the command line. -{click.style("SCRIPT", fg="green")} Path to the script to submit. +{click.style("SCRIPT...", fg="green")} One or more paths to the scripts to submit. All the options can also be specified inside the submitted script itself using qq directives of this format: `# qq