From be88d2639e68c16a15a4205e4cc59f4c712185d6 Mon Sep 17 00:00:00 2001 From: Ladme Date: Mon, 27 Apr 2026 12:30:56 +0200 Subject: [PATCH 01/43] Updated installation scripts to report issues more clearly --- CHANGELOG.md | 5 +++++ .../qq-karolina-install.sh | 20 ++++++++++++++++--- .../installation_scripts/qq-lumi-install.sh | 20 ++++++++++++++++--- .../qq-metacentrum-install.sh | 20 ++++++++++++++++--- .../installation_scripts/qq-robox-install.sh | 20 ++++++++++++++++--- .../installation_scripts/qq-sokar-install.sh | 20 ++++++++++++++++--- 6 files changed, 90 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8af7699..6a11aa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## Version 0.11 +- Updated the installation scripts to more clearly report issues that occured during the install. + +*** + ## Version 0.10.1 - The minimal width of the job status info panel was slightly increased to better accomodate long node names. - **Bug fix:** None is no longer displayed in the subtitle of the panel when using `qq nodes`. diff --git a/scripts/installation_scripts/qq-karolina-install.sh b/scripts/installation_scripts/qq-karolina-install.sh index a011ad0..cec4db3 100755 --- a/scripts/installation_scripts/qq-karolina-install.sh +++ b/scripts/installation_scripts/qq-karolina-install.sh @@ -1,6 +1,6 @@ #!/bin/bash # Installs qq on IT4Innovations' Karolina. -# Script version: 0.3.0 +# Script version: 0.4.0 set -euo pipefail @@ -32,6 +32,8 @@ chmod +x "$TMP_INSTALLER" echo "INFO [qq karolina installer] Installing qq ${QQ_VERSION} from ${RELEASE_URL}" +MISSING_HOMES=() + for HOME_DIR in "${TARGET_HOMES[@]}"; do echo "--------------------------------------------" echo "INFO [qq karolina installer] Installing qq into $HOME_DIR ..." @@ -39,12 +41,24 @@ for HOME_DIR in "${TARGET_HOMES[@]}"; do "$TMP_INSTALLER" "$HOME_DIR" "$RELEASE_URL" else echo "WARN [qq karolina installer] Skipping $HOME_DIR (directory not found)" + MISSING_HOMES+=("$HOME_DIR") fi done echo "--------------------------------------------" -echo "INFO [qq karolina installer] qq installation completed for all target home directories." -echo "INFO [qq karolina installer] Run 'source ${HOME}/.bashrc' to make qq available on the current machine." + +if [ ${#MISSING_HOMES[@]} -gt 0 ]; then + echo "WARN [qq karolina installer] qq installation failed for the following home directories:" + for MISSING in "${MISSING_HOMES[@]}"; do + echo "WARN [qq karolina installer] - $MISSING" + done +else + echo "INFO [qq karolina installer] qq installation completed for all target home directories." +fi + +if [[ ! " ${MISSING_HOMES[*]} " == *" ${HOME} "* ]]; then + echo "INFO [qq karolina installer] Run 'source ${HOME}/.bashrc' to make qq available on the current machine." +fi # Cleanup rm -f "$TMP_INSTALLER" diff --git a/scripts/installation_scripts/qq-lumi-install.sh b/scripts/installation_scripts/qq-lumi-install.sh index 08f75bf..f136756 100755 --- a/scripts/installation_scripts/qq-lumi-install.sh +++ b/scripts/installation_scripts/qq-lumi-install.sh @@ -1,6 +1,6 @@ #!/bin/bash # Installs qq on the Lumi supercomputer. -# Script version: 0.2.0 +# Script version: 0.3.0 set -euo pipefail @@ -32,6 +32,8 @@ chmod +x "$TMP_INSTALLER" echo "INFO [qq lumi installer] Installing qq ${QQ_VERSION} from ${RELEASE_URL}" +MISSING_HOMES=() + for HOME_DIR in "${TARGET_HOMES[@]}"; do echo "--------------------------------------------" echo "INFO [qq lumi installer] Installing qq into $HOME_DIR ..." @@ -39,12 +41,24 @@ for HOME_DIR in "${TARGET_HOMES[@]}"; do "$TMP_INSTALLER" "$HOME_DIR" "$RELEASE_URL" else echo "WARN [qq lumi installer] Skipping $HOME_DIR (directory not found)" + MISSING_HOMES+=("$HOME_DIR") fi done echo "--------------------------------------------" -echo "INFO [qq lumi installer] qq installation completed for all target home directories." -echo "INFO [qq lumi installer] Run 'source ${HOME}/.bashrc' to make qq available on the current machine." + +if [ ${#MISSING_HOMES[@]} -gt 0 ]; then + echo "WARN [qq lumi installer] qq installation failed for the following home directories:" + for MISSING in "${MISSING_HOMES[@]}"; do + echo "WARN [qq lumi installer] - $MISSING" + done +else + echo "INFO [qq lumi installer] qq installation completed for all target home directories." +fi + +if [[ ! " ${MISSING_HOMES[*]} " == *" ${HOME} "* ]]; then + echo "INFO [qq lumi installer] Run 'source ${HOME}/.bashrc' to make qq available on the current machine." +fi # Cleanup rm -f "$TMP_INSTALLER" diff --git a/scripts/installation_scripts/qq-metacentrum-install.sh b/scripts/installation_scripts/qq-metacentrum-install.sh index 6e658d2..0ecc2da 100755 --- a/scripts/installation_scripts/qq-metacentrum-install.sh +++ b/scripts/installation_scripts/qq-metacentrum-install.sh @@ -1,6 +1,6 @@ #!/bin/bash # Installs qq on your current desktop and on the computing nodes of all Metacentrum clusters. -# Script version: 0.5.0 +# Script version: 0.6.0 set -euo pipefail @@ -78,6 +78,8 @@ echo "INFO [qq metacentrum installer] Downloading qq setup from ${SETUP_SCRIP curl -fsSL -o "$TMP_SETUP" "$SETUP_SCRIPT_URL" chmod +x "$TMP_SETUP" +MISSING_HOMES=() + for HOME_DIR in "${TARGET_HOMES[@]}"; do echo "--------------------------------------------" echo "INFO [qq metacentrum installer] Linking qq to ${HOME_DIR}..." @@ -85,6 +87,7 @@ for HOME_DIR in "${TARGET_HOMES[@]}"; do "$TMP_SETUP" "$HOME_DIR" "${MAIN_HOME}/qq" else echo "WARN [qq metacentrum installer] Skipping ${HOME_DIR} (directory not found)" + MISSING_HOMES+=("$HOME_DIR") fi done @@ -101,8 +104,19 @@ for HOST in "${LOCAL_HOME_HOSTS[@]}"; do done echo "--------------------------------------------" -echo "INFO [qq metacentrum installer] qq installation completed for all target home directories." -echo "INFO [qq metacentrum installer] Run 'source ${HOME}/.bashrc' to make qq available on the current machine." + +if [ ${#MISSING_HOMES[@]} -gt 0 ]; then + echo "WARN [qq metacentrum installer] qq installation failed for the following home directories:" + for MISSING in "${MISSING_HOMES[@]}"; do + echo "WARN [qq metacentrum installer] - $MISSING" + done +else + echo "INFO [qq metacentrum installer] qq installation completed for all target home directories." +fi + +if [[ ! " ${MISSING_HOMES[*]} " == *" ${HOME} "* ]]; then + echo "INFO [qq metacentrum installer] Run 'source ${HOME}/.bashrc' to make qq available on the current machine." +fi # Cleanup rm -f "$TMP_INSTALLER" diff --git a/scripts/installation_scripts/qq-robox-install.sh b/scripts/installation_scripts/qq-robox-install.sh index 889e72a..850f195 100755 --- a/scripts/installation_scripts/qq-robox-install.sh +++ b/scripts/installation_scripts/qq-robox-install.sh @@ -1,6 +1,6 @@ #!/bin/bash # Installs qq on your current desktop and on the computing nodes of the robox cluster. -# Script version: 0.4.0 +# Script version: 0.5.0 set -euo pipefail @@ -33,6 +33,8 @@ chmod +x "$TMP_INSTALLER" echo "INFO [qq robox installer] Installing qq ${QQ_VERSION} from ${RELEASE_URL}" +MISSING_HOMES=() + for HOME_DIR in "${TARGET_HOMES[@]}"; do echo "--------------------------------------------" echo "INFO [qq robox installer] Installing qq into $HOME_DIR ..." @@ -40,12 +42,24 @@ for HOME_DIR in "${TARGET_HOMES[@]}"; do "$TMP_INSTALLER" "$HOME_DIR" "$RELEASE_URL" else echo "WARN [qq robox installer] Skipping $HOME_DIR (directory not found)" + MISSING_HOMES+=("$HOME_DIR") fi done echo "--------------------------------------------" -echo "INFO [qq robox installer] qq installation completed for all target home directories." -echo "INFO [qq robox installer] Run 'source ${HOME}/.bashrc' to make qq available on the current machine." + +if [ ${#MISSING_HOMES[@]} -gt 0 ]; then + echo "WARN [qq robox installer] qq installation failed for the following home directories:" + for MISSING in "${MISSING_HOMES[@]}"; do + echo "WARN [qq robox installer] - $MISSING" + done +else + echo "INFO [qq robox installer] qq installation completed for all target home directories." +fi + +if [[ ! " ${MISSING_HOMES[*]} " == *" ${HOME} "* ]]; then + echo "INFO [qq robox installer] Run 'source ${HOME}/.bashrc' to make qq available on the current machine." +fi # Cleanup rm -f "$TMP_INSTALLER" diff --git a/scripts/installation_scripts/qq-sokar-install.sh b/scripts/installation_scripts/qq-sokar-install.sh index eb855c8..fe6126c 100755 --- a/scripts/installation_scripts/qq-sokar-install.sh +++ b/scripts/installation_scripts/qq-sokar-install.sh @@ -1,6 +1,6 @@ #!/bin/bash # Installs qq on all nodes of the sokar cluster. -# Script version: 0.4.0 +# Script version: 0.5.0 set -euo pipefail @@ -32,6 +32,8 @@ chmod +x "$TMP_INSTALLER" echo "INFO [qq sokar installer] Installing qq ${QQ_VERSION} from ${RELEASE_URL}" +MISSING_HOMES=() + for HOME_DIR in "${TARGET_HOMES[@]}"; do echo "--------------------------------------------" echo "INFO [qq sokar installer] Installing qq into $HOME_DIR ..." @@ -39,12 +41,24 @@ for HOME_DIR in "${TARGET_HOMES[@]}"; do "$TMP_INSTALLER" "$HOME_DIR" "$RELEASE_URL" else echo "WARN [qq sokar installer] Skipping $HOME_DIR (directory not found)" + MISSING_HOMES+=("$HOME_DIR") fi done echo "--------------------------------------------" -echo "INFO [qq sokar installer] qq installation completed for all target home directories." -echo "INFO [qq sokar installer] Run 'source ${HOME}/.bashrc' to make qq available on the current machine." + +if [ ${#MISSING_HOMES[@]} -gt 0 ]; then + echo "WARN [qq sokar installer] qq installation failed for the following home directories:" + for MISSING in "${MISSING_HOMES[@]}"; do + echo "WARN [qq sokar installer] - $MISSING" + done +else + echo "INFO [qq sokar installer] qq installation completed for all target home directories." +fi + +if [[ ! " ${MISSING_HOMES[*]} " == *" ${HOME} "* ]]; then + echo "INFO [qq sokar installer] Run 'source ${HOME}/.bashrc' to make qq available on the current machine." +fi # Cleanup rm -f "$TMP_INSTALLER" From 3ce50ae5a9f2f1297e3308179e43e020e610fc04 Mon Sep 17 00:00:00 2001 From: Kikoun18 <82326436+Kikoun18@users.noreply.github.com> Date: Fri, 1 May 2026 15:47:56 +0200 Subject: [PATCH 02/43] gmx-eta accepts more arguments Updated gmx eta to accept more arguments now, being able to print more jobs at once this way --- scripts/qq_scripts/gmx-eta | 73 ++++++++++++++++++++------------------ 1 file changed, 39 insertions(+), 34 deletions(-) diff --git a/scripts/qq_scripts/gmx-eta b/scripts/qq_scripts/gmx-eta index 5630c5b..1b4b71c 100755 --- a/scripts/qq_scripts/gmx-eta +++ b/scripts/qq_scripts/gmx-eta @@ -61,44 +61,49 @@ def main(): "gmx-eta", description="Get the estimated time of a Gromacs simulation finishing.", ) - parser.add_argument("job_id", nargs="?", help="Job ID. Optional.", default=None) + parser.add_argument("job_id", nargs="*", help="Job ID(s). Optional.", default=[None]) args = parser.parse_args() - informer = get_informer(args.job_id) - navigator = Navigator.from_informer(informer) - - if (main_node := navigator.get_main_node()) and ( - work_dir := navigator.get_work_dir() - ): - BatchSystem = BatchMeta.from_env_var_or_guess() - # use the batch system to read the remote file with Gromacs output - # split the lines and reverse the content to read from the end - try: - content = reversed( - BatchSystem.read_remote_file( - main_node, work_dir / informer.info.stderr_file - ).splitlines() - ) - except Exception as e: - print(f"No information is available: {e}") - return + job_ids = args.job_id if args.job_id else [None] + + for job_id in job_ids: + informer = get_informer(job_id) + navigator = Navigator.from_informer(informer) + + jobname = f"[yellow bold]{informer.info.job_name}[/yellow bold]" + + if (main_node := navigator.get_main_node()) and ( + work_dir := navigator.get_work_dir() + ): + BatchSystem = BatchMeta.from_env_var_or_guess() + # use the batch system to read the remote file with Gromacs output + # split the lines and reverse the content to read from the end + try: + content = reversed( + BatchSystem.read_remote_file( + main_node, work_dir / informer.info.stderr_file + ).splitlines() + ) + except Exception as e: + print(f"{jobname}: No information is available: {e}") + return + + # get eta + eta = get_eta_from_content(content) + if eta and datetime.now() <= eta: + print( + f"{jobname}: Simulation will finish in [bright_blue bold]{format_duration_wdhhmmss(eta - datetime.now())}[/bright_blue bold]." + ) + elif eta and datetime.now() > eta: + print( + f"{jobname}: Simulation has finished at [bright_green bold]{str(eta)}[/bright_green bold]." + ) + else: + print("No information is available.") - # get eta - eta = get_eta_from_content(content) - if eta and datetime.now() <= eta: - print( - f"Simulation will finish in [bright_blue bold]{format_duration_wdhhmmss(eta - datetime.now())}[/bright_blue bold]." - ) - elif eta and datetime.now() > eta: - print( - f"Simulation has finished at [bright_green bold]{str(eta)}[/bright_green bold]." - ) else: - print("No information is available.") - - else: - print("No information is available: job does not have a working directory.") - return + print(f"{jobname}: No information is available: job does not have a working directory.") + return if __name__ == "__main__": From 91ac6adcd7257517cf45a92cdbbc00345c041738 Mon Sep 17 00:00:00 2001 From: Kikoun18 <82326436+Kikoun18@users.noreply.github.com> Date: Fri, 1 May 2026 23:28:11 +0200 Subject: [PATCH 03/43] Added --all option With these changes, it is now possible to use -a (or --all) arg which will print all currently running jobs --- scripts/qq_scripts/{gmx-eta => gmx-eta.py} | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) rename scripts/qq_scripts/{gmx-eta => gmx-eta.py} (89%) mode change 100755 => 100644 diff --git a/scripts/qq_scripts/gmx-eta b/scripts/qq_scripts/gmx-eta.py old mode 100755 new mode 100644 similarity index 89% rename from scripts/qq_scripts/gmx-eta rename to scripts/qq_scripts/gmx-eta.py index 1b4b71c..13bd474 --- a/scripts/qq_scripts/gmx-eta +++ b/scripts/qq_scripts/gmx-eta.py @@ -20,6 +20,7 @@ # /// import argparse +import getpass from datetime import datetime from pathlib import Path @@ -37,6 +38,11 @@ def get_informer(id: str | None) -> Informer: return Informer.from_job_id(id) return Informer.from_file(get_info_files(Path())[-1]) +def get_all_job_ids() -> list[str]: + batch_system = BatchMeta.from_env_var_or_guess() + jobs = batch_system.get_unfinished_batch_jobs(getpass.getuser(), None) + return [job.get_id() for job in jobs] + def get_eta_from_content(content: str) -> datetime | None: """Get the time at which the Gromacs simulation is expected to finish.""" @@ -62,9 +68,11 @@ def main(): description="Get the estimated time of a Gromacs simulation finishing.", ) parser.add_argument("job_id", nargs="*", help="Job ID(s). Optional.", default=[None]) + parser.add_argument("--all", "-a", action="store_true", help="Show ETA for all jobs.") + args = parser.parse_args() - job_ids = args.job_id if args.job_id else [None] + job_ids = get_all_job_ids() if args.all else (args.job_id or [None]) for job_id in job_ids: informer = get_informer(job_id) From 85841b85c028436ef1696de1bf5fd53afaf4c6ad Mon Sep 17 00:00:00 2001 From: Ladme Date: Mon, 4 May 2026 10:14:07 +0200 Subject: [PATCH 04/43] Clean-up and some refactoring The updated gmx-eta script from #9 has been refactored to be more idiomatic. Additionally, job id is now shown next to the job name. --- scripts/qq_scripts/gmx-eta | 158 ++++++++++++++++++++++++++++++++++ scripts/qq_scripts/gmx-eta.py | 118 ------------------------- 2 files changed, 158 insertions(+), 118 deletions(-) create mode 100755 scripts/qq_scripts/gmx-eta delete mode 100644 scripts/qq_scripts/gmx-eta.py diff --git a/scripts/qq_scripts/gmx-eta b/scripts/qq_scripts/gmx-eta new file mode 100755 index 0000000..9ed92bb --- /dev/null +++ b/scripts/qq_scripts/gmx-eta @@ -0,0 +1,158 @@ +#!/usr/bin/env -S uv run --script + +# Released under MIT License. +# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab + +""" +Get the estimated time of a Gromacs simulation finishing. +Version qq 0.10.1. +Requires `uv`: https://docs.astral.sh/uv +""" + +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "qq", +# ] +# +# [tool.uv.sources] +# qq = { git = "https://github.com/Ladme/qq.git", tag = "v0.10.1" } +# /// + +import argparse +import getpass +from collections.abc import Iterator +from datetime import datetime +from pathlib import Path + +from rich import print + +from qq_lib.batch.interface import BatchInterface, BatchMeta +from qq_lib.core.common import format_duration_wdhhmmss, get_info_files +from qq_lib.core.navigator import Navigator +from qq_lib.info import Informer + + +def get_informers_for_all_jobs(BatchSystem: type[BatchInterface]) -> list[Informer]: + """Get informers for all unfinished jobs of the current user.""" + jobs = BatchSystem.get_unfinished_batch_jobs(getpass.getuser(), None) + informers = [] + for job in jobs: + try: + informers.append(Informer.from_batch_job(job)) + except Exception as e: + print(f"Could not get information about job {job.get_id()}: {e}.") + continue + + return informers + + +def get_informers_from_job_ids(ids: list[str]) -> list[Informer]: + """Get informers for all provided job IDs.""" + informers = [] + for id in ids: + try: + informers.append(Informer.from_job_id(id)) + except Exception as e: + print(f"Could not get information about job {id}: {e}.") + continue + + return informers + + +def get_eta_from_content(content: Iterator[str]) -> datetime: + """Get the time at which the Gromacs simulation is expected to finish.""" + + # find the last line containing the ETA + eta_line = next((s for s in content if "will finish" in s), None) + if not eta_line: + raise RuntimeError("Did not find information about ETA.") + + # assuming the time information is stored in the last 5 words + eta = " ".join(eta_line.split()[-5:]) + + return datetime.strptime(eta, "%a %b %d %H:%M:%S %Y") + + +def get_content_from_job( + informer: Informer, BatchSystem: type[BatchInterface] +) -> Iterator[str]: + """Get the reversed content of the standard error output file for the job.""" + navigator = Navigator.from_informer(informer) + + if (main_node := navigator.get_main_node()) and ( + work_dir := navigator.get_work_dir() + ): + # use the batch system to read the remote file with Gromacs output + # split the lines and reverse the content to read from the end + return reversed( + BatchSystem.read_remote_file( + main_node, work_dir / informer.info.stderr_file + ).splitlines() + ) + + raise RuntimeError("Job does not have a working directory.") + + +def main(): + # parse command line options + parser = argparse.ArgumentParser( + "gmx-eta", + description="Get the estimated time of a Gromacs simulation finishing.", + ) + parser.add_argument( + "job_id", + nargs="*", + help="Job ID(s). Optional. If not provided, ETA is obtained for the newest job submitted from the current directory.", + default=[], + ) + parser.add_argument( + "--all", "-a", action="store_true", help="Show ETA for all jobs." + ) + + args = parser.parse_args() + + # detect batch system to use + BatchSystem = BatchMeta.from_env_var_or_guess() + + if args.all: + # get informers for all unfinished jobs of the user + informers = get_informers_for_all_jobs(BatchSystem) + elif len(args.job_id) > 0: + # get informers from the IDs specified on the command line + informers = get_informers_from_job_ids(args.job_id) + else: + # get informer from the newest job submitted from the current directory + try: + informers = [Informer.from_file(get_info_files(Path())[-1])] + except Exception: + print("No job detected in the current directory.") + return + + for informer in informers: + job_id_name = f"[cyan bold][{informer.info.job_id.split('.')[0]}][/cyan bold] [yellow bold]{informer.info.job_name}[/yellow bold]" + + # get content of the Gromacs output + try: + content = get_content_from_job(informer, BatchSystem) + except Exception as e: + print(f"{job_id_name}: No information is available: {e}") + continue + + # extract ETA from the content + try: + eta = get_eta_from_content(content) + if eta and datetime.now() <= eta: + print( + f"{job_id_name}: Simulation will finish in [bright_blue bold]{format_duration_wdhhmmss(eta - datetime.now())}[/bright_blue bold]." + ) + else: + print( + f"{job_id_name}: Simulation has finished at [bright_green bold]{str(eta)}[/bright_green bold]." + ) + except Exception as e: + print(f"{job_id_name}: No information is available: {e}") + + +if __name__ == "__main__": + main() diff --git a/scripts/qq_scripts/gmx-eta.py b/scripts/qq_scripts/gmx-eta.py deleted file mode 100644 index 13bd474..0000000 --- a/scripts/qq_scripts/gmx-eta.py +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env -S uv run --script - -# Released under MIT License. -# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab - -""" -Get the estimated time of a Gromacs simulation finishing. -Version qq 0.10.0. -Requires `uv`: https://docs.astral.sh/uv -""" - -# /// script -# requires-python = ">=3.12" -# dependencies = [ -# "qq", -# ] -# -# [tool.uv.sources] -# qq = { git = "https://github.com/Ladme/qq.git", tag = "v0.10.1" } -# /// - -import argparse -import getpass -from datetime import datetime -from pathlib import Path - -from rich import print - -from qq_lib.batch.interface import BatchMeta -from qq_lib.core.common import format_duration_wdhhmmss, get_info_files -from qq_lib.core.navigator import Navigator -from qq_lib.info import Informer - - -def get_informer(id: str | None) -> Informer: - """Get informer for the given job id or for the newest job in the current directory.""" - if id: - return Informer.from_job_id(id) - return Informer.from_file(get_info_files(Path())[-1]) - -def get_all_job_ids() -> list[str]: - batch_system = BatchMeta.from_env_var_or_guess() - jobs = batch_system.get_unfinished_batch_jobs(getpass.getuser(), None) - return [job.get_id() for job in jobs] - - -def get_eta_from_content(content: str) -> datetime | None: - """Get the time at which the Gromacs simulation is expected to finish.""" - - # find the last line containing the ETA - eta_line = next((s for s in content if "will finish" in s), None) - if not eta_line: - return None - - # assuming the time information is stored in the last 5 words - eta = " ".join(eta_line.split()[-5:]) - - try: - return datetime.strptime(eta, "%a %b %d %H:%M:%S %Y") - except Exception: - return None - - -def main(): - # parse command line options - parser = argparse.ArgumentParser( - "gmx-eta", - description="Get the estimated time of a Gromacs simulation finishing.", - ) - parser.add_argument("job_id", nargs="*", help="Job ID(s). Optional.", default=[None]) - parser.add_argument("--all", "-a", action="store_true", help="Show ETA for all jobs.") - - args = parser.parse_args() - - job_ids = get_all_job_ids() if args.all else (args.job_id or [None]) - - for job_id in job_ids: - informer = get_informer(job_id) - navigator = Navigator.from_informer(informer) - - jobname = f"[yellow bold]{informer.info.job_name}[/yellow bold]" - - if (main_node := navigator.get_main_node()) and ( - work_dir := navigator.get_work_dir() - ): - BatchSystem = BatchMeta.from_env_var_or_guess() - # use the batch system to read the remote file with Gromacs output - # split the lines and reverse the content to read from the end - try: - content = reversed( - BatchSystem.read_remote_file( - main_node, work_dir / informer.info.stderr_file - ).splitlines() - ) - except Exception as e: - print(f"{jobname}: No information is available: {e}") - return - - # get eta - eta = get_eta_from_content(content) - if eta and datetime.now() <= eta: - print( - f"{jobname}: Simulation will finish in [bright_blue bold]{format_duration_wdhhmmss(eta - datetime.now())}[/bright_blue bold]." - ) - elif eta and datetime.now() > eta: - print( - f"{jobname}: Simulation has finished at [bright_green bold]{str(eta)}[/bright_green bold]." - ) - else: - print("No information is available.") - - else: - print(f"{jobname}: No information is available: job does not have a working directory.") - return - - -if __name__ == "__main__": - main() From d30c1d1ebd8a3ba8b7ad57aec0cfebd8ce0c05e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ladislav=20Barto=C5=A1?= Date: Mon, 4 May 2026 20:10:40 +0200 Subject: [PATCH 05/43] Updated to python 3.13; updated ty; resolved type checker errors --- .python-version | 2 +- CHANGELOG.md | 5 +- pyproject.toml | 5 +- scripts/qq_scripts/gmx-eta | 2 +- scripts/qq_scripts/multi-check | 4 +- scripts/qq_scripts/multi-kill | 4 +- scripts/qq_scripts/multi-submit | 4 +- src/qq_lib/archive/archiver.py | 6 +- src/qq_lib/batch/interface/__init__.py | 3 +- src/qq_lib/batch/interface/interface.py | 28 ++++---- src/qq_lib/batch/interface/meta.py | 3 +- src/qq_lib/cd/cder.py | 32 +++++++-- src/qq_lib/jobs/presenter.py | 5 +- src/qq_lib/properties/info.py | 8 +-- src/qq_lib/properties/states.py | 30 ++------- src/qq_lib/submit/submitter.py | 8 +-- tests/test_batch_interface.py | 7 +- tests/test_cd_cder.py | 12 ++++ tests/test_jobs_presenter.py | 8 ++- tests/test_nodes_presenter.py | 10 ++- tests/test_properties_info.py | 14 ++++ tests/test_queues_presenter.py | 8 ++- uv.lock | 88 +++++++------------------ 23 files changed, 148 insertions(+), 148 deletions(-) diff --git a/.python-version b/.python-version index e4fba21..24ee5b1 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.12 +3.13 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a11aa9..ffd2859 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ ## Version 0.11 - Updated the installation scripts to more clearly report issues that occured during the install. +### Internal changes +- qq now uses Python 3.13 for better generics support. + *** ## Version 0.10.1 @@ -171,4 +174,4 @@ ### Internal changes - Renamed PBSJobInfo to PBSJob. -- Set up GitHub Actions to take care of releases. \ No newline at end of file +- Set up GitHub Actions to take care of releases. diff --git a/pyproject.toml b/pyproject.toml index f8c5011..909c6a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "qq" description = "A friendly interface to batch processing" readme = "README.md" -requires-python = ">=3.12" +requires-python = ">=3.13" license = { text = "MIT" } authors = [{ name = "Ladislav Bartos", email = "ladmeb@gmail.com" }] keywords = ["batch", "processing", "cli", "automation"] @@ -16,7 +16,6 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Scientific/Engineering", "Topic :: System :: Distributed Computing", @@ -51,7 +50,7 @@ dev = [ "pytest-cov>=7.0.0", "ruff>=0.13.0", "snakeviz>=2.2.2", - "ty>=0.0.18", + "ty>=0.0.34", ] diff --git a/scripts/qq_scripts/gmx-eta b/scripts/qq_scripts/gmx-eta index 9ed92bb..4c4f214 100755 --- a/scripts/qq_scripts/gmx-eta +++ b/scripts/qq_scripts/gmx-eta @@ -10,7 +10,7 @@ Requires `uv`: https://docs.astral.sh/uv """ # /// script -# requires-python = ">=3.12" +# requires-python = ">=3.13" # dependencies = [ # "qq", # ] diff --git a/scripts/qq_scripts/multi-check b/scripts/qq_scripts/multi-check index 0b9720d..e8e8ff7 100755 --- a/scripts/qq_scripts/multi-check +++ b/scripts/qq_scripts/multi-check @@ -5,12 +5,12 @@ """ Check the state of qq jobs in multiple directories. -Version qq 0.10.0. +Version qq 0.10.1. Requires `uv`: https://docs.astral.sh/uv """ # /// script -# requires-python = ">=3.12" +# requires-python = ">=3.13" # dependencies = [ # "qq", # ] diff --git a/scripts/qq_scripts/multi-kill b/scripts/qq_scripts/multi-kill index ffcf25a..2fc9ba5 100755 --- a/scripts/qq_scripts/multi-kill +++ b/scripts/qq_scripts/multi-kill @@ -5,12 +5,12 @@ """ Kill qq jobs in multiple directories. -Version qq 0.10.0. +Version qq 0.10.1. Requires `uv`: https://docs.astral.sh/uv """ # /// script -# requires-python = ">=3.12" +# requires-python = ">=3.13" # dependencies = [ # "qq", # ] diff --git a/scripts/qq_scripts/multi-submit b/scripts/qq_scripts/multi-submit index abc6660..4c9d0f9 100755 --- a/scripts/qq_scripts/multi-submit +++ b/scripts/qq_scripts/multi-submit @@ -5,12 +5,12 @@ """ Submit qq jobs from multiple directories. -Version qq 0.10.0. +Version qq 0.10.1. Requires `uv`: https://docs.astral.sh/uv """ # /// script -# requires-python = ">=3.12" +# requires-python = ">=3.13" # dependencies = [ # "qq", # ] diff --git a/src/qq_lib/archive/archiver.py b/src/qq_lib/archive/archiver.py index 9956efa..d8b8448 100644 --- a/src/qq_lib/archive/archiver.py +++ b/src/qq_lib/archive/archiver.py @@ -6,7 +6,7 @@ from collections.abc import Iterable from pathlib import Path -from qq_lib.batch.interface import BatchInterface +from qq_lib.batch.interface import AnyBatchClass from qq_lib.core.common import is_printf_pattern, printf_to_regex from qq_lib.core.config import CFG from qq_lib.core.logger import get_logger @@ -27,7 +27,7 @@ def __init__( archive_format: str, input_machine: str, input_dir: Path, - batch_system: type[BatchInterface], + batch_system: AnyBatchClass, ): """ Initialize the Archiver. @@ -37,7 +37,7 @@ def __init__( archive_format (str): Printf-style or regex pattern describing archived filenames. input_machine (str): The hostname from which the job was submitted. input_dir (Path): The directory from which the job was submitted. - batch_system (type[BatchInterface]): The batch system which manages the job. + batch_system (AnyBatchClass): The batch system which manages the job. """ self._batch_system = batch_system self._archive = archive diff --git a/src/qq_lib/batch/interface/__init__.py b/src/qq_lib/batch/interface/__init__.py index 33d0d84..7b742a5 100644 --- a/src/qq_lib/batch/interface/__init__.py +++ b/src/qq_lib/batch/interface/__init__.py @@ -24,7 +24,7 @@ implementations automatically. """ -from .interface import BatchInterface +from .interface import AnyBatchClass, BatchInterface from .job import BatchJobInterface from .meta import BatchMeta from .node import BatchNodeInterface @@ -36,4 +36,5 @@ "BatchMeta", "BatchNodeInterface", "BatchQueueInterface", + "AnyBatchClass", ] diff --git a/src/qq_lib/batch/interface/interface.py b/src/qq_lib/batch/interface/interface.py index b171012..a8209b9 100644 --- a/src/qq_lib/batch/interface/interface.py +++ b/src/qq_lib/batch/interface/interface.py @@ -6,6 +6,7 @@ import subprocess from abc import ABC from pathlib import Path +from typing import Any from qq_lib.core.common import convert_absolute_to_relative from qq_lib.core.config import CFG @@ -21,11 +22,16 @@ logger = get_logger(__name__) +""" +Type alias for a batch system class. +""" +type AnyBatchClass = type[BatchInterface[Any, Any, Any]] + class BatchInterface[ - TBatchJob: BatchJobInterface, - TBatchQueue: BatchQueueInterface, - TBatchNode: BatchNodeInterface, + TBatchJob: BatchJobInterface = BatchJobInterface, + TBatchQueue: BatchQueueInterface = BatchQueueInterface, + TBatchNode: BatchNodeInterface = BatchNodeInterface, ](ABC): """ Abstract base class for batch system integrations. @@ -179,7 +185,7 @@ def get_batch_job(cls, job_id: str) -> TBatchJob: job_id (str): Identifier of the job. Returns: - TBatchJob: Object containing the job's metadata and state. + BatchJobInterface: Object containing the job's metadata and state. """ raise NotImplementedError( f"get_batch_job method is not implemented for {cls.__name__}" @@ -200,7 +206,7 @@ def get_unfinished_batch_jobs( server (str | None): Optional name of the batch server to get jobs from. Returns: - list[TBatchJob]: A list of job info objects representing the user's uncompleted jobs. + list[BatchJobInterface]: A list of job info objects representing the user's uncompleted jobs. """ raise NotImplementedError( f"get_unfinished_batch_jobs method is not implemented for {cls.__name__}" @@ -219,7 +225,7 @@ def get_batch_jobs(cls, user: str, server: str | None = None) -> list[TBatchJob] server (str | None): Optional name of the batch server to get jobs from. Returns: - list[TBatchJob]: A list of job info objects representing all jobs of the user. + list[BatchJobInterface]: A list of job info objects representing all jobs of the user. """ raise NotImplementedError( f"get_batch_jobs method is not implemented for {cls.__name__}" @@ -238,7 +244,7 @@ def get_all_unfinished_batch_jobs( server (str | None): Optional name of the batch server to get jobs from. Returns: - list[TBatchJob]: A list of job info objects representing uncompleted jobs of all users. + list[BatchJobInterface]: A list of job info objects representing uncompleted jobs of all users. """ raise NotImplementedError( f"get_all_unfinished_batch_jobs method is not implemented for {cls.__name__}" @@ -255,7 +261,7 @@ def get_all_batch_jobs(cls, server: str | None = None) -> list[TBatchJob]: server (str | None): Optional name of the batch server to get jobs from. Returns: - list[TBatchJob]: A list of job info objects representing all jobs of all users. + list[BatchJobInterface]: A list of job info objects representing all jobs of all users. """ raise NotImplementedError( f"get_all_batch_jobs method is not implemented for {cls.__name__}" @@ -270,7 +276,7 @@ def get_queues(cls, server: str | None = None) -> list[TBatchQueue]: server (str | None): Optional name of the batch server to get queues from. Returns: - list[TBatchQueue]: A list of queue objects existing in the batch system. + list[BatchQueueInterface]: A list of queue objects existing in the batch system. """ raise NotImplementedError( f"get_queues method is not implemented for {cls.__name__}" @@ -285,7 +291,7 @@ def get_nodes(cls, server: str | None = None) -> list[TBatchNode]: server (str | None): Optional name of the batch server to get nodes from. Returns: - list[TBatchNode]: A list of node objects existing in the batch system. + list[BatchNodeInterface]: A list of node objects existing in the batch system. """ raise NotImplementedError( f"get_nodes method is not implemented for {cls.__name__}" @@ -797,7 +803,7 @@ def sort_jobs(cls, jobs: list[TBatchJob]) -> None: implement custom sorting logic. Args: - jobs (list[TBatchJob]): A list of batch job objects to be sorted + jobs (list[BatchJobInterface]): A list of batch job objects to be sorted in-place. """ jobs.sort(key=lambda job: job.get_id()) diff --git a/src/qq_lib/batch/interface/meta.py b/src/qq_lib/batch/interface/meta.py index e49c54f..886051b 100644 --- a/src/qq_lib/batch/interface/meta.py +++ b/src/qq_lib/batch/interface/meta.py @@ -5,6 +5,7 @@ import os from abc import ABCMeta +from qq_lib.batch.interface import AnyBatchClass from qq_lib.core.config import CFG from qq_lib.core.error import QQError from qq_lib.core.logger import get_logger @@ -29,7 +30,7 @@ def __str__(cls: type[BatchInterface]) -> str: return cls.env_name() @classmethod - def register_batch_system(cls, batch_cls: type[BatchInterface]) -> None: + def register_batch_system(cls, batch_cls: AnyBatchClass) -> None: """ Register a batch system class in the metaclass registry. diff --git a/src/qq_lib/cd/cder.py b/src/qq_lib/cd/cder.py index f21ed4d..5d136ea 100644 --- a/src/qq_lib/cd/cder.py +++ b/src/qq_lib/cd/cder.py @@ -4,7 +4,13 @@ from pathlib import Path -from qq_lib.batch.interface import BatchInterface, BatchJobInterface +from qq_lib.batch.interface import ( + AnyBatchClass, + BatchInterface, + BatchJobInterface, + BatchNodeInterface, + BatchQueueInterface, +) from qq_lib.core.error import QQError from qq_lib.core.logger import get_logger @@ -16,12 +22,16 @@ class Cder: Retrieve and provide the input directory for a specific job in the configured batch system. """ - def __init__(self, BatchSystem: type[BatchInterface], job_id: str): + def __init__( + self, + BatchSystem: AnyBatchClass, + job_id: str, + ): """ Initialize the Cder instance with a batch system interface and job ID. Args: - BatchSystem (type[BatchInterface]): The batch system which manages the job. + BatchSystem (AnyBatchClass): The batch system which manages the job. job_id (str): Identifier of the job to query. """ self._job_id = job_id @@ -42,9 +52,14 @@ def cd(self) -> str: return str(path) @staticmethod - def _get_input_dir_from_job_id( - BatchSystem: type[BatchInterface], job_id: str - ) -> Path: + def _get_input_dir_from_job_id[ + TBatchJob: BatchJobInterface, + TBatchQueue: BatchQueueInterface, + TBatchNode: BatchNodeInterface, + ]( + BatchSystem: type[BatchInterface[TBatchJob, TBatchQueue, TBatchNode]], + job_id: str, + ) -> Path | None: """ Query the batch system for the input directory of a job. @@ -63,4 +78,7 @@ def _get_input_dir_from_job_id( if job_info.is_empty(): raise QQError(f"Job '{job_id}' does not exist.") - return job_info.get_input_dir() + if not (input_dir := job_info.get_input_dir()): + raise QQError(f"Job '{job_id}' has an unknown input directory.") + + return input_dir diff --git a/src/qq_lib/jobs/presenter.py b/src/qq_lib/jobs/presenter.py index adf8304..516287d 100644 --- a/src/qq_lib/jobs/presenter.py +++ b/src/qq_lib/jobs/presenter.py @@ -10,8 +10,7 @@ from rich.text import Text from tabulate import Line, TableFormat, tabulate -from qq_lib.batch.interface import BatchJobInterface -from qq_lib.batch.interface.interface import BatchInterface +from qq_lib.batch.interface import AnyBatchClass, BatchJobInterface from qq_lib.core.common import ( format_duration_wdhhmmss, get_panel_width, @@ -73,7 +72,7 @@ class JobsPresenter: def __init__( self, - batch_system: type[BatchInterface], + batch_system: AnyBatchClass, jobs: list[BatchJobInterface], extra: bool, all: bool, diff --git a/src/qq_lib/properties/info.py b/src/qq_lib/properties/info.py index 3d736b7..fa7024f 100644 --- a/src/qq_lib/properties/info.py +++ b/src/qq_lib/properties/info.py @@ -22,7 +22,7 @@ import yaml -from qq_lib.batch.interface import BatchInterface, BatchMeta +from qq_lib.batch.interface import AnyBatchClass, BatchMeta from qq_lib.core.common import load_yaml_dumper, load_yaml_loader from qq_lib.core.config import CFG from qq_lib.core.error import QQError @@ -52,7 +52,7 @@ class Info: """ # The batch system class used - batch_system: type[BatchInterface] + batch_system: AnyBatchClass # Version of qq that submitted the job qq_version: str @@ -318,7 +318,7 @@ def _to_dict(self) -> dict[str, object]: # convert the state and the batch system elif ( f.type == NaiveState - or f.type == type[BatchInterface] + or f.type == AnyBatchClass or f.type == Path or f.type == Path | None ): @@ -372,7 +372,7 @@ def _from_dict(cls, data: dict[str, object]) -> Self: elif f.type == Resources: init_kwargs[name] = Resources(**value) # ty: ignore[invalid-argument-type] # convert the batch system - elif f.type == type[BatchInterface] and isinstance(value, str): + elif f.type == AnyBatchClass and isinstance(value, str): init_kwargs[name] = BatchMeta.from_str(value) # convert the job state elif f.type == NaiveState and isinstance(value, str): diff --git a/src/qq_lib/properties/states.py b/src/qq_lib/properties/states.py index f3e0075..b6d814b 100644 --- a/src/qq_lib/properties/states.py +++ b/src/qq_lib/properties/states.py @@ -143,18 +143,9 @@ def color(self) -> str: Returns: str: A string representing the color for presentation purposes. """ - return { - self.QUEUED: RealState.QUEUED.color, - self.HELD: RealState.HELD.color, - self.SUSPENDED: RealState.SUSPENDED.color, - self.WAITING: RealState.WAITING.color, - self.RUNNING: RealState.RUNNING.color, - self.FAILED: RealState.FAILED.color, - self.FINISHED: RealState.FINISHED.color, - self.EXITING: RealState.EXITING.color, - self.MOVING: RealState.QUEUED.color, - self.UNKNOWN: RealState.UNKNOWN.color, - }[self] + if self is BatchState.MOVING: + return RealState.QUEUED.color + return RealState[self.name].color class RealState(Enum): @@ -266,17 +257,4 @@ def color(self) -> str: Returns: str: A string representing the color for presentation purposes. """ - return { - self.QUEUED: CFG.state_colors.queued, - self.HELD: CFG.state_colors.held, - self.SUSPENDED: CFG.state_colors.suspended, - self.WAITING: CFG.state_colors.waiting, - self.RUNNING: CFG.state_colors.running, - self.BOOTING: CFG.state_colors.booting, - self.KILLED: CFG.state_colors.killed, - self.FAILED: CFG.state_colors.failed, - self.FINISHED: CFG.state_colors.finished, - self.EXITING: CFG.state_colors.exiting, - self.IN_AN_INCONSISTENT_STATE: CFG.state_colors.in_an_inconsistent_state, - self.UNKNOWN: CFG.state_colors.unknown, - }[self] + return getattr(CFG.state_colors, self.name.lower()) diff --git a/src/qq_lib/submit/submitter.py b/src/qq_lib/submit/submitter.py index fa43a53..c64cc96 100644 --- a/src/qq_lib/submit/submitter.py +++ b/src/qq_lib/submit/submitter.py @@ -9,7 +9,7 @@ from pathlib import Path import qq_lib -from qq_lib.batch.interface import BatchInterface +from qq_lib.batch.interface import AnyBatchClass from qq_lib.core.common import ( construct_info_file_path, construct_loop_job_name, @@ -48,7 +48,7 @@ class Submitter: def __init__( self, - batch_system: type[BatchInterface], + batch_system: AnyBatchClass, queue: str, account: str | None, script: Path, @@ -66,7 +66,7 @@ def __init__( Initialize a Submitter instance. Args: - batch_system (type[BatchInterface]): The batch system class implementing + batch_system (AnyBatchClass): The batch system class implementing the BatchInterface used for job submission. queue (str): The name of the batch system queue to which the job will be submitted. account (str | None): The name of the account to use for the job. @@ -267,7 +267,7 @@ def get_input_dir(self) -> Path: """ return self._input_dir - def get_batch_system(self) -> type[BatchInterface]: + def get_batch_system(self) -> AnyBatchClass: """Get the batch system used for submiting.""" return self._batch_system diff --git a/tests/test_batch_interface.py b/tests/test_batch_interface.py index cea8fcb..42d1065 100644 --- a/tests/test_batch_interface.py +++ b/tests/test_batch_interface.py @@ -4,11 +4,12 @@ import os import subprocess from pathlib import Path +from typing import cast from unittest.mock import MagicMock, patch import pytest -from qq_lib.batch.interface import BatchInterface, BatchMeta +from qq_lib.batch.interface import BatchInterface, BatchJobInterface, BatchMeta from qq_lib.batch.interface.interface import CFG from qq_lib.batch.pbs import PBS from qq_lib.core.error import QQError @@ -648,14 +649,14 @@ def get_id(self): def test_batch_interface_sort_jobs_sorts_by_id(): jobs = [DummyJob("c"), DummyJob("a"), DummyJob("b")] - BatchInterface.sort_jobs(jobs) + BatchInterface.sort_jobs(cast("list[BatchJobInterface]", jobs)) ids = [job.get_id() for job in jobs] assert ids == ["a", "b", "c"] def test_batch_interface_sort_jobs_with_numeric_ids(): jobs = [DummyJob("10"), DummyJob("2"), DummyJob("1")] - BatchInterface.sort_jobs(jobs) + BatchInterface.sort_jobs(cast("list[BatchJobInterface]", jobs)) ids = [job.get_id() for job in jobs] assert ids == ["1", "10", "2"] diff --git a/tests/test_cd_cder.py b/tests/test_cd_cder.py index c5cdb81..8b8ca49 100644 --- a/tests/test_cd_cder.py +++ b/tests/test_cd_cder.py @@ -46,6 +46,18 @@ def test_cder_cd_success_inf_input_dir(): assert cder.cd() == "/infinity/input/dir" +def test_cder_cd_failure_no_input_dir(): + env_vars = "OTHER_VAR=123" + job_info = _make_jobinfo_with_info({"Variable_List": env_vars}) + + with ( + patch.object(PBS, "get_batch_job", return_value=job_info), + pytest.raises(QQError, match="has an unknown input directory"), + ): + cder = Cder(PBS, "1234") + cder.cd() + + def test_cder_cd_does_not_exist(): job_info_empty = _make_jobinfo_with_info({}) diff --git a/tests/test_jobs_presenter.py b/tests/test_jobs_presenter.py index 0779020..2cccb37 100644 --- a/tests/test_jobs_presenter.py +++ b/tests/test_jobs_presenter.py @@ -5,6 +5,7 @@ import io import sys from datetime import datetime, timedelta +from typing import TYPE_CHECKING from unittest.mock import Mock, patch import pytest @@ -20,11 +21,14 @@ from qq_lib.jobs.presenter import CFG, JobsPresenter, JobsStatistics from qq_lib.properties.states import BatchState +if TYPE_CHECKING: + from qq_lib.batch.interface import BatchJobInterface + def test_init_sets_all_attributes_and_creates_statistics(): job1 = Mock() job2 = Mock() - jobs = [job1, job2] + jobs: list[BatchJobInterface] = [job1, job2] with patch("qq_lib.jobs.presenter.JobsStatistics") as mock_stats_class: mock_stats_instance = Mock() @@ -44,7 +48,7 @@ def test_init_sets_all_attributes_and_creates_statistics(): def test_init_with_server_sets_all_attributes_and_creates_statistics(): job1 = Mock() job2 = Mock() - jobs = [job1, job2] + jobs: list[BatchJobInterface] = [job1, job2] with patch("qq_lib.jobs.presenter.JobsStatistics") as mock_stats_class: mock_stats_instance = Mock() diff --git a/tests/test_nodes_presenter.py b/tests/test_nodes_presenter.py index 48e1db9..2404978 100644 --- a/tests/test_nodes_presenter.py +++ b/tests/test_nodes_presenter.py @@ -4,6 +4,7 @@ import sys from io import StringIO +from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch import pytest @@ -19,6 +20,9 @@ from qq_lib.nodes.presenter import NodeGroup, NodeGroupStats, NodesPresenter from qq_lib.properties.size import Size +if TYPE_CHECKING: + from qq_lib.batch.interface import BatchNodeInterface + @patch.object(NodeGroup, "_should_show_properties", return_value=True) @patch.object(NodeGroup, "_should_show_shared_scratch", return_value=True) @@ -38,7 +42,7 @@ def test_node_group_init( mock_show_shared, mock_show_props, ): - nodes = [MagicMock(), MagicMock()] + nodes: list[BatchNodeInterface] = [MagicMock(), MagicMock()] group = NodeGroup("gpu_nodes", nodes, "user1") mock_sort.assert_called_once() @@ -652,7 +656,7 @@ def test_node_group_stats_create_stats_table_excludes_gpu_columns_when_none( return_value=["group1", "group2"], ) def test_nodes_presenter_init(mock_create_groups): - nodes = [MagicMock(), MagicMock()] + nodes: list[BatchNodeInterface] = [MagicMock(), MagicMock()] presenter = NodesPresenter(nodes, "user1", True, None) assert presenter._nodes == nodes @@ -668,7 +672,7 @@ def test_nodes_presenter_init(mock_create_groups): return_value=["group1", "group2"], ) def test_nodes_presenter_init_with_server(mock_create_groups): - nodes = [MagicMock(), MagicMock()] + nodes: list[BatchNodeInterface] = [MagicMock(), MagicMock()] presenter = NodesPresenter(nodes, "user1", True, "server") assert presenter._nodes == nodes diff --git a/tests/test_properties_info.py b/tests/test_properties_info.py index 4c07c24..06fa532 100644 --- a/tests/test_properties_info.py +++ b/tests/test_properties_info.py @@ -11,6 +11,7 @@ from qq_lib.batch.interface import BatchMeta from qq_lib.batch.pbs import PBS +from qq_lib.batch.slurmit4i import SlurmIT4I from qq_lib.core.error import QQError from qq_lib.properties.info import CFG, Info from qq_lib.properties.job_type import JobType @@ -109,6 +110,19 @@ def test_to_yaml_contains_fields(sample_info): assert data["transfer_mode"] == ["success"] +def test_to_yaml_with_slurm_contains_fields(sample_info): + sample_info.batch_system = SlurmIT4I + yaml_str = sample_info._to_yaml() + data: dict[str, Any] = yaml.safe_load(yaml_str) + + assert data["batch_system"] == "SlurmIT4I" + assert data["job_id"] == "12345.fake.server.com" + assert data["job_name"] == "script.sh+025" + assert data["resources"]["ncpus"] == 8 + assert data["account"] == "fake-account" + assert data["transfer_mode"] == ["success"] + + def test_to_yaml_skips_none_fields(sample_info): yaml_str = sample_info._to_yaml() data: dict[str, Any] = yaml.safe_load(yaml_str) diff --git a/tests/test_queues_presenter.py b/tests/test_queues_presenter.py index f94c6c5..c851e8e 100644 --- a/tests/test_queues_presenter.py +++ b/tests/test_queues_presenter.py @@ -5,6 +5,7 @@ import sys from datetime import timedelta from io import StringIO +from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch import pytest @@ -18,9 +19,12 @@ from qq_lib.core.config import CFG from qq_lib.queues.presenter import QueuesPresenter +if TYPE_CHECKING: + from qq_lib.batch.interface import BatchQueueInterface + def test_queues_presenter_init_sets_fields_correctly(): - queues = [MagicMock(), MagicMock()] + queues: list[BatchQueueInterface] = [MagicMock(), MagicMock()] user = "user" display_all = True @@ -33,7 +37,7 @@ def test_queues_presenter_init_sets_fields_correctly(): def test_queues_presenter_init_with_server_sets_fields_correctly(): - queues = [MagicMock(), MagicMock()] + queues: list[BatchQueueInterface] = [MagicMock(), MagicMock()] user = "user" display_all = True server = "server" diff --git a/uv.lock b/uv.lock index 85c1d86..412d948 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.12" +requires-python = ">=3.13" [[package]] name = "altgraph" @@ -35,17 +35,6 @@ version = "3.4.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, - { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, - { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, - { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, - { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, - { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, - { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, - { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, - { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, - { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, @@ -135,19 +124,6 @@ version = "7.10.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, - { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, - { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, - { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, - { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, - { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, - { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, - { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, - { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, - { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, - { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, @@ -299,17 +275,6 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, @@ -536,15 +501,6 @@ version = "6.0.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, - { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, - { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, - { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, - { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, - { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, @@ -603,7 +559,7 @@ dev = [ { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "ruff", specifier = ">=0.13.0" }, { name = "snakeviz", specifier = ">=2.2.2" }, - { name = "ty", specifier = ">=0.0.18" }, + { name = "ty", specifier = ">=0.0.34" }, ] [[package]] @@ -720,26 +676,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.18" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/74/15/9682700d8d60fdca7afa4febc83a2354b29cdcd56e66e19c92b521db3b39/ty-0.0.18.tar.gz", hash = "sha256:04ab7c3db5dcbcdac6ce62e48940d3a0124f377c05499d3f3e004e264ae94b83", size = 5214774, upload-time = "2026-02-20T21:51:31.173Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/d8/920460d4c22ea68fcdeb0b2fb53ea2aeb9c6d7875bde9278d84f2ac767b6/ty-0.0.18-py3-none-linux_armv6l.whl", hash = "sha256:4e5e91b0a79857316ef893c5068afc4b9872f9d257627d9bc8ac4d2715750d88", size = 10280825, upload-time = "2026-02-20T21:51:25.03Z" }, - { url = "https://files.pythonhosted.org/packages/83/56/62587de582d3d20d78fcdddd0594a73822ac5a399a12ef512085eb7a4de6/ty-0.0.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee0e578b3f8416e2d5416da9553b78fd33857868aa1384cb7fefeceee5ff102d", size = 10118324, upload-time = "2026-02-20T21:51:22.27Z" }, - { url = "https://files.pythonhosted.org/packages/2f/2d/dbdace8d432a0755a7417f659bfd5b8a4261938ecbdfd7b42f4c454f5aa9/ty-0.0.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3f7a0487d36b939546a91d141f7fc3dbea32fab4982f618d5b04dc9d5b6da21e", size = 9605861, upload-time = "2026-02-20T21:51:16.066Z" }, - { url = "https://files.pythonhosted.org/packages/6b/d9/de11c0280f778d5fc571393aada7fe9b8bc1dd6a738f2e2c45702b8b3150/ty-0.0.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5e2fa8d45f57ca487a470e4bf66319c09b561150e98ae2a6b1a97ef04c1a4eb", size = 10092701, upload-time = "2026-02-20T21:51:26.862Z" }, - { url = "https://files.pythonhosted.org/packages/0f/94/068d4d591d791041732171e7b63c37a54494b2e7d28e88d2167eaa9ad875/ty-0.0.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d75652e9e937f7044b1aca16091193e7ef11dac1c7ec952b7fb8292b7ba1f5f2", size = 10109203, upload-time = "2026-02-20T21:51:11.59Z" }, - { url = "https://files.pythonhosted.org/packages/34/e4/526a4aa56dc0ca2569aaa16880a1ab105c3b416dd70e87e25a05688999f3/ty-0.0.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:563c868edceb8f6ddd5e91113c17d3676b028f0ed380bdb3829b06d9beb90e58", size = 10614200, upload-time = "2026-02-20T21:51:20.298Z" }, - { url = "https://files.pythonhosted.org/packages/fd/3d/b68ab20a34122a395880922587fbfc3adf090d22e0fb546d4d20fe8c2621/ty-0.0.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:502e2a1f948bec563a0454fc25b074bf5cf041744adba8794d024277e151d3b0", size = 11153232, upload-time = "2026-02-20T21:51:14.121Z" }, - { url = "https://files.pythonhosted.org/packages/68/ea/678243c042343fcda7e6af36036c18676c355878dcdcd517639586d2cf9e/ty-0.0.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc881dea97021a3aa29134a476937fd8054775c4177d01b94db27fcfb7aab65b", size = 10832934, upload-time = "2026-02-20T21:51:32.92Z" }, - { url = "https://files.pythonhosted.org/packages/d8/bd/7f8d647cef8b7b346c0163230a37e903c7461c7248574840b977045c77df/ty-0.0.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:421fcc3bc64cab56f48edb863c7c1c43649ec4d78ff71a1acb5366ad723b6021", size = 10700888, upload-time = "2026-02-20T21:51:09.673Z" }, - { url = "https://files.pythonhosted.org/packages/6e/06/cb3620dc48c5d335ba7876edfef636b2f4498eff4a262ff90033b9e88408/ty-0.0.18-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0fe5038a7136a0e638a2fb1ad06e3d3c4045314c6ba165c9c303b9aeb4623d6c", size = 10078965, upload-time = "2026-02-20T21:51:07.678Z" }, - { url = "https://files.pythonhosted.org/packages/60/27/c77a5a84533fa3b685d592de7b4b108eb1f38851c40fac4e79cc56ec7350/ty-0.0.18-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d123600a52372677613a719bbb780adeb9b68f47fb5f25acb09171de390e0035", size = 10134659, upload-time = "2026-02-20T21:51:18.311Z" }, - { url = "https://files.pythonhosted.org/packages/43/6e/60af6b88c73469e628ba5253a296da6984e0aa746206f3034c31f1a04ed1/ty-0.0.18-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bb4bc11d32a1bf96a829bf6b9696545a30a196ac77bbc07cc8d3dfee35e03723", size = 10297494, upload-time = "2026-02-20T21:51:39.631Z" }, - { url = "https://files.pythonhosted.org/packages/33/90/612dc0b68224c723faed6adac2bd3f930a750685db76dfe17e6b9e534a83/ty-0.0.18-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dda2efbf374ba4cd704053d04e32f2f784e85c2ddc2400006b0f96f5f7e4b667", size = 10791944, upload-time = "2026-02-20T21:51:37.13Z" }, - { url = "https://files.pythonhosted.org/packages/0d/da/f4ada0fd08a9e4138fe3fd2bcd3797753593f423f19b1634a814b9b2a401/ty-0.0.18-py3-none-win32.whl", hash = "sha256:c5768607c94977dacddc2f459ace6a11a408a0f57888dd59abb62d28d4fee4f7", size = 9677964, upload-time = "2026-02-20T21:51:42.039Z" }, - { url = "https://files.pythonhosted.org/packages/5e/fa/090ed9746e5c59fc26d8f5f96dc8441825171f1f47752f1778dad690b08b/ty-0.0.18-py3-none-win_amd64.whl", hash = "sha256:b78d0fa1103d36fc2fce92f2092adace52a74654ab7884d54cdaec8eb5016a4d", size = 10636576, upload-time = "2026-02-20T21:51:29.159Z" }, - { url = "https://files.pythonhosted.org/packages/92/4f/5dd60904c8105cda4d0be34d3a446c180933c76b84ae0742e58f02133713/ty-0.0.18-py3-none-win_arm64.whl", hash = "sha256:01770c3c82137c6b216aa3251478f0b197e181054ee92243772de553d3586398", size = 10095449, upload-time = "2026-02-20T21:51:34.914Z" }, +version = "0.0.34" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/69/e24eefe2c35c0fdbdec9b60e162727af669bb76d64d993d982eb67b24c38/ty-0.0.34.tar.gz", hash = "sha256:a6efe66b0f13c03a65e6c72ec9abfe2792e2fd063c74fa67e2c4930e29d661be", size = 5585933, upload-time = "2026-05-01T23:06:46.388Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/7b/8b85003d6639ef17a97dcbb31f4511cfe78f1c81a964470db100c8c883e7/ty-0.0.34-py3-none-linux_armv6l.whl", hash = "sha256:9ecc3d14f07a95a6ceb88e07f8e62358dbd37325d3d5bd56da7217ff1fef7fb8", size = 11067094, upload-time = "2026-05-01T23:06:21.133Z" }, + { url = "https://files.pythonhosted.org/packages/d7/25/b0098f65b020b015c40567c763fc66fffbec88b2ba6f584bca1e92f05ebb/ty-0.0.34-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0dccffd8a9d02321cd2dee3249df205e26d62694e741f4eeca36b157fd8b419f", size = 10840909, upload-time = "2026-05-01T23:06:18.409Z" }, + { url = "https://files.pythonhosted.org/packages/e4/55/5e4adcf7d2a1006b844903b27cb81244a9b748d850433a46a6c21776c401/ty-0.0.34-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b0ea47a2998e167ab3b21d2f4b5309a9cf33c297809f6d7e3e753252223174d0", size = 10279378, upload-time = "2026-05-01T23:06:37.962Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/f537dca0db8fe2558e8ab04d8941d687b384fcc1df5eb9023b2db75ac26c/ty-0.0.34-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b37da00b41a118a459ae56d8947e70651073fb33ebfbceb820e4a10b22d5023", size = 10817423, upload-time = "2026-05-01T23:06:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c4/55a3ad1da2815af1009bdc1b8c90dc11a364cd314e4b48c5128ba9d38859/ty-0.0.34-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81cbbb93c2342fe3de43e625d3a9eb149633e9f485e816ebf6395d08685355d8", size = 10851826, upload-time = "2026-05-01T23:06:24.198Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/9c7606af22d73fb43ea4369472d9c66ece11231be73b0efe8e3c61655559/ty-0.0.34-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c5b4dea1594a021289e172582df9cde7089dce14b276fc650e7b212b1772e12", size = 11356318, upload-time = "2026-05-01T23:06:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/20/54/bb423f663721ab4138b216425c6b55eaefd3a068243b24d6d8fe988f4e13/ty-0.0.34-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:030fb00aa2d2a5b5ae9d9183d574e0c82dae80566700a7490c43669d8ece40cd", size = 11902968, upload-time = "2026-05-01T23:06:35.82Z" }, + { url = "https://files.pythonhosted.org/packages/b6/22/01122b21ab6b534a2f618c6bbe5f1f7f49fd56f4b2ec8887cd6d40d08fb3/ty-0.0.34-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ae9555e24e36c63a8218e037a5a63f15579eb6aa94f41017e57cd41d335cfb5", size = 11548860, upload-time = "2026-05-01T23:06:42.155Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/86008b1392ec64bed1957bbcc7aaa43b466b50dfc91bb131841c21d7c5c3/ty-0.0.34-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99eb23df9ed129fc26d1ab00d6f0b8dfe5253b09c2ac6abdb11523fa70d67f10", size = 11457097, upload-time = "2026-05-01T23:06:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/92/3e/4558b2296963ba99c58d8409c57d7db4f3061b656c3613cb21c02c1ef4c2/ty-0.0.34-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:85de45382016eceae69e104815eb2cfa200787df104002e262a86cbd43ed2c02", size = 10798192, upload-time = "2026-05-01T23:06:40.004Z" }, + { url = "https://files.pythonhosted.org/packages/76/bf/650d24402be2ef678528d60caac1d9477a40fc37e3792ecef07834fd7a4a/ty-0.0.34-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:14cb575fb8fa5131f5129d100cfe23c1575d23faf5dfc5158432749a3e38c9b5", size = 10890390, upload-time = "2026-05-01T23:06:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ef/ccd2ca13906079f7935fd7e067661b24233017f57d987d51d6a121d85bb5/ty-0.0.34-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c6fc0b69d8450e6910ba9db34572b959b81329a97ae273c391f70e9fb6c1aade", size = 11031564, upload-time = "2026-05-01T23:06:55.812Z" }, + { url = "https://files.pythonhosted.org/packages/ba/2d/d27b72005b6f43599e3bcabab0d7135ac0c230b7a307bb99f9eea02c1cda/ty-0.0.34-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:30dfcec2f0fde3993f4f912ed0e057dcbebc8615299f610a4c2ddb7b5a3e1e06", size = 11553430, upload-time = "2026-05-01T23:06:31.096Z" }, + { url = "https://files.pythonhosted.org/packages/a7/12/20812e1ad930b8d4af70eebf19ad23cff6e31efcfa613ef884531fcdbaa1/ty-0.0.34-py3-none-win32.whl", hash = "sha256:97b77ddf007271b812a313a8f0a14929bc5590958433e1fb83ef585676f53342", size = 10436048, upload-time = "2026-05-01T23:06:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/afa095c5987868fbda27c0f731146ac8e3d07b357adfa83daccaee5b1a16/ty-0.0.34-py3-none-win_amd64.whl", hash = "sha256:1f543968accb952705134028d1fda8656882787dbbc667ad4d6c3ba23791d604", size = 11462526, upload-time = "2026-05-01T23:06:28.514Z" }, + { url = "https://files.pythonhosted.org/packages/63/8f/bf041a06260d77662c0605e56dacfe90b786bf824cbe1aed238d15fe5e84/ty-0.0.34-py3-none-win_arm64.whl", hash = "sha256:ea09108cbcb16b6b06d7596312b433bf49681e78d30e4dc7fb3c1b248a95e09a", size = 10846945, upload-time = "2026-05-01T23:06:44.428Z" }, ] [[package]] From 3c674df7797c33d43c1af2edee2e5ec9d7f2c516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ladislav=20Barto=C5=A1?= Date: Mon, 4 May 2026 20:18:48 +0200 Subject: [PATCH 06/43] Fixed type errors in qq scripts --- CHANGELOG.md | 1 + scripts/qq_scripts/gmx-eta | 2 +- scripts/qq_scripts/multi-check | 15 ++++++++------- scripts/qq_scripts/multi-kill | 10 ++++++---- scripts/qq_scripts/multi-submit | 2 +- 5 files changed, 17 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffd2859..06e398c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Internal changes - qq now uses Python 3.13 for better generics support. +- Fixed type errors in qq scripts. *** diff --git a/scripts/qq_scripts/gmx-eta b/scripts/qq_scripts/gmx-eta index 4c4f214..4b3f20b 100755 --- a/scripts/qq_scripts/gmx-eta +++ b/scripts/qq_scripts/gmx-eta @@ -16,7 +16,7 @@ Requires `uv`: https://docs.astral.sh/uv # ] # # [tool.uv.sources] -# qq = { git = "https://github.com/Ladme/qq.git", tag = "v0.10.1" } +# qq = { git = "https://github.com/VachaLab/qq.git", tag = "v0.10.1" } # /// import argparse diff --git a/scripts/qq_scripts/multi-check b/scripts/qq_scripts/multi-check index e8e8ff7..3db3a20 100755 --- a/scripts/qq_scripts/multi-check +++ b/scripts/qq_scripts/multi-check @@ -16,7 +16,7 @@ Requires `uv`: https://docs.astral.sh/uv # ] # # [tool.uv.sources] -# qq = { git = "https://github.com/Ladme/qq.git", tag = "v0.10.1" } +# qq = { git = "https://github.com/VachaLab/qq.git", tag = "v0.10.1" } # /// import argparse @@ -53,11 +53,11 @@ class Job: def get_informer(directory: str) -> Informer | None: """Get informer for the newest job in the specified directory.""" # check that the directory is actually a directory and convert it to Path - if not (directory := Path(directory)).is_dir(): + if not (directory_path := Path(directory)).is_dir(): return None # get all info files in the directory - info_files = get_info_files(directory) + info_files = get_info_files(directory_path) # if no qq info files are found, return None if not info_files: @@ -114,9 +114,10 @@ def fix_job(job: Job) -> tuple[Job, bool]: informer.info.excluded_files, informer.info.included_files, # remove dependencies - informer.info.transfer_mode, - informer.info.server, - informer.info.interpreter, + depend=None, + transfer_mode=informer.info.transfer_mode, + server=informer.info.server, + interpreter=informer.info.interpreter, ) submitter.submit() return (job, True) @@ -174,7 +175,7 @@ def get_jobs_and_states( return states -def fix_jobs(states: dict[RealState, set[Path]]) -> tuple[set[Job], set[Job]]: +def fix_jobs(states: dict[RealState, set[Job]]) -> tuple[set[Job], set[Job]]: """Fix all failed and killed jobs by deleting their working directories and resubmitting them.""" # get failed nad killed jobs jobs = states.get(RealState.FAILED, set()).union( diff --git a/scripts/qq_scripts/multi-kill b/scripts/qq_scripts/multi-kill index 2fc9ba5..27e9c5a 100755 --- a/scripts/qq_scripts/multi-kill +++ b/scripts/qq_scripts/multi-kill @@ -16,7 +16,7 @@ Requires `uv`: https://docs.astral.sh/uv # ] # # [tool.uv.sources] -# qq = { git = "https://github.com/Ladme/qq.git", tag = "v0.10.1" } +# qq = { git = "https://github.com/VachaLab/qq.git", tag = "v0.10.1" } # /// import argparse @@ -40,11 +40,11 @@ logging.disable(logging.ERROR) def get_killer(directory: str) -> Killer | None: """Get killer for the newest job in the specified directory.""" # check that the directory is actually a directory and convert it to Path - if not (directory := Path(directory)).is_dir(): + if not (directory_path := Path(directory)).is_dir(): return None # get all info files in the directory - info_files = get_info_files(directory) + info_files = get_info_files(directory_path) # if no qq info files are found, return None if not info_files: @@ -111,7 +111,9 @@ def kill_directories( with ThreadPoolExecutor(max_workers=threads) as executor: # submit each job to the thread pool # `futures` maps Future objects to directories - futures = {executor.submit(process_directory, d): d for d in directories} + futures = { + executor.submit(process_directory, str(d)): d for d in directories + } # iterate over futures as they finish (in arbitrary order) for future in as_completed(futures): diff --git a/scripts/qq_scripts/multi-submit b/scripts/qq_scripts/multi-submit index 4c9d0f9..027e23c 100755 --- a/scripts/qq_scripts/multi-submit +++ b/scripts/qq_scripts/multi-submit @@ -16,7 +16,7 @@ Requires `uv`: https://docs.astral.sh/uv # ] # # [tool.uv.sources] -# qq = { git = "https://github.com/Ladme/qq.git", tag = "v0.10.1" } +# qq = { git = "https://github.com/VachaLab/qq.git", tag = "v0.10.1" } # /// import argparse From ea5ca7b7f333b7c87f3e25b3e42d3c38c2b4dda0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ladislav=20Barto=C5=A1?= Date: Mon, 4 May 2026 20:26:52 +0200 Subject: [PATCH 07/43] Changed CI workflow to run on push to any branch --- .github/workflows/ci.yml | 44 +++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a70e66..e55df2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,40 +2,38 @@ name: CI on: push: - branches: [ main ] pull_request: - branches: [ main ] jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v5 - - name: Install uv - uses: astral-sh/setup-uv@v7 - with: - enable-cache: true + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true - - name: Install dependencies - run: uv sync --all-groups + - name: Install dependencies + run: uv sync --all-groups - - name: Run ruff check - run: uv run ruff check . + - name: Run ruff check + run: uv run ruff check . - - name: Run ruff format check - run: uv run ruff format --check . + - name: Run ruff format check + run: uv run ruff format --check . - - name: Run ty check - run: uv run ty check . + - name: Run ty check + run: uv run ty check . - - name: Run pytest - run: uv run pytest --cov --cov-report=xml + - name: Run pytest + run: uv run pytest --cov --cov-report=xml - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: ./coverage.xml - fail_ci_if_error: false \ No newline at end of file + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: ./coverage.xml + fail_ci_if_error: false From 85867618e3d71fb8f89807cc7cdb7e3abcee4c6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ladislav=20Barto=C5=A1?= Date: Mon, 4 May 2026 20:29:00 +0200 Subject: [PATCH 08/43] Updated license year --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 39cf6fb..df6ab5b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Ladislav Bartos and Robert Vacha Lab +Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 7f4ff333c363a00a2856a0d4bf19320dad5aea98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ladislav=20Barto=C5=A1?= Date: Mon, 4 May 2026 22:11:21 +0200 Subject: [PATCH 09/43] BatchMeta refactor --- CHANGELOG.md | 1 + scripts/qq_scripts/gmx-eta | 4 +- src/qq_lib/batch/__init__.py | 8 -- src/qq_lib/batch/interface/__init__.py | 2 - src/qq_lib/batch/interface/interface.py | 110 ++++++++++++++++++- src/qq_lib/batch/interface/meta.py | 137 ------------------------ src/qq_lib/batch/pbs/pbs.py | 6 +- src/qq_lib/batch/slurm/slurm.py | 4 +- src/qq_lib/batch/slurmit4i/slurm.py | 4 +- src/qq_lib/batch/slurmlumi/slurm.py | 4 +- src/qq_lib/cd/cli.py | 4 +- src/qq_lib/core/common.py | 13 +-- src/qq_lib/info/informer.py | 5 +- src/qq_lib/jobs/cli.py | 12 +-- src/qq_lib/killall/cli.py | 4 +- src/qq_lib/nodes/cli.py | 4 +- src/qq_lib/properties/info.py | 6 +- src/qq_lib/queues/cli.py | 4 +- src/qq_lib/run/runner.py | 4 +- src/qq_lib/stat/cli.py | 12 +-- src/qq_lib/submit/factory.py | 6 +- src/qq_lib/submit/parser.py | 4 +- tests/test_batch_interface.py | 66 ++++++------ tests/test_cd_cli.py | 14 +-- tests/test_core_common.py | 12 +-- tests/test_info_informer.py | 4 +- tests/test_jobs_cli.py | 14 +-- tests/test_killall_cli.py | 40 +++++-- tests/test_nodes_cli.py | 14 +-- tests/test_properties_info.py | 4 +- tests/test_queues_cli.py | 14 +-- tests/test_run_runner.py | 22 ++-- tests/test_stat_cli.py | 14 +-- tests/test_submit_factory.py | 7 +- tests/test_submit_parser.py | 11 +- 35 files changed, 282 insertions(+), 312 deletions(-) delete mode 100644 src/qq_lib/batch/interface/meta.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 06e398c..fbbdde5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Internal changes - qq now uses Python 3.13 for better generics support. - Fixed type errors in qq scripts. +- Refactored BatchMeta. *** diff --git a/scripts/qq_scripts/gmx-eta b/scripts/qq_scripts/gmx-eta index 4b3f20b..8d8347f 100755 --- a/scripts/qq_scripts/gmx-eta +++ b/scripts/qq_scripts/gmx-eta @@ -27,7 +27,7 @@ from pathlib import Path from rich import print -from qq_lib.batch.interface import BatchInterface, BatchMeta +from qq_lib.batch.interface import BatchInterface from qq_lib.core.common import format_duration_wdhhmmss, get_info_files from qq_lib.core.navigator import Navigator from qq_lib.info import Informer @@ -113,7 +113,7 @@ def main(): args = parser.parse_args() # detect batch system to use - BatchSystem = BatchMeta.from_env_var_or_guess() + BatchSystem = BatchInterface.from_env_var_or_guess() if args.all: # get informers for all unfinished jobs of the user diff --git a/src/qq_lib/batch/__init__.py b/src/qq_lib/batch/__init__.py index e254456..8f6feba 100644 --- a/src/qq_lib/batch/__init__.py +++ b/src/qq_lib/batch/__init__.py @@ -9,11 +9,3 @@ together with the concrete backends for PBS, Slurm, and site-specific Slurm variants. """ - -# import so that these batch systems are available but do not export them from here -from .pbs import PBS as _PBS -from .slurm import Slurm as _Slurm -from .slurmit4i import SlurmIT4I as _SlurmIT4I -from .slurmlumi import SlurmLumi as _SlurmLumi - -_PBS, _Slurm, _SlurmIT4I, _SlurmLumi diff --git a/src/qq_lib/batch/interface/__init__.py b/src/qq_lib/batch/interface/__init__.py index 7b742a5..3477ae4 100644 --- a/src/qq_lib/batch/interface/__init__.py +++ b/src/qq_lib/batch/interface/__init__.py @@ -26,14 +26,12 @@ from .interface import AnyBatchClass, BatchInterface from .job import BatchJobInterface -from .meta import BatchMeta from .node import BatchNodeInterface from .queue import BatchQueueInterface __all__ = [ "BatchInterface", "BatchJobInterface", - "BatchMeta", "BatchNodeInterface", "BatchQueueInterface", "AnyBatchClass", diff --git a/src/qq_lib/batch/interface/interface.py b/src/qq_lib/batch/interface/interface.py index a8209b9..9972f27 100644 --- a/src/qq_lib/batch/interface/interface.py +++ b/src/qq_lib/batch/interface/interface.py @@ -1,25 +1,30 @@ # Released under MIT License. # Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab +from __future__ import annotations +import inspect +import os import socket import subprocess -from abc import ABC +from abc import ABC, ABCMeta from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any, cast from qq_lib.core.common import convert_absolute_to_relative 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.core.logical_paths import logical_resolve -from qq_lib.properties.depend import Depend -from qq_lib.properties.resources import Resources from .job import BatchJobInterface from .node import BatchNodeInterface from .queue import BatchQueueInterface +if TYPE_CHECKING: + from qq_lib.properties.depend import Depend + from qq_lib.properties.resources import Resources + logger = get_logger(__name__) """ @@ -28,11 +33,106 @@ type AnyBatchClass = type[BatchInterface[Any, Any, Any]] +class _BatchMeta(ABCMeta): + """ + Metaclass for batch system classes. + + Provides automatic registration of concrete subclasses and lookup + methods that are callable directly on BatchInterface (or any class + using this metaclass). + + Examples: + BatchSystem = BatchInterface.from_env_var_or_guess() + BatchSystem = BatchInterface.from_str("PBS") + """ + + _registry: dict[str, AnyBatchClass] = {} + + def __init__(cls, name: str, bases: tuple[type, ...], namespace: dict) -> None: + super().__init__(name, bases, namespace) + # register every concrete (non-abstract) subclass automatically + if any(isinstance(b, _BatchMeta) for b in bases) and not inspect.isabstract( + cls + ): + batch_cls = cast("type[BatchInterface]", cls) + _BatchMeta._registry[batch_cls.env_name()] = batch_cls + + def __str__(cls: type[BatchInterface]) -> str: + """ + Get the string representation of the batch system class. + """ + return cls.env_name() + + def from_str(cls, name: str) -> type[BatchInterface]: + """ + Return the batch system class registered under the given name. + + Raises: + QQError: If no class is registered for the given name. + """ + try: + return _BatchMeta._registry[name] + except KeyError as e: + raise QQError(f"No batch system registered as '{name}'.") from e + + def guess(cls) -> type[BatchInterface]: + """ + Return the first registered batch system that reports itself + as available. + + Raises: + QQError: If no available batch system is found. + """ + for BatchSystem in _BatchMeta._registry.values(): + if BatchSystem.is_available(): + logger.debug(f"Guessed batch system: {BatchSystem}.") + return BatchSystem + + raise QQError( + "Could not guess a batch system. No registered batch system available." + ) + + def from_env_var_or_guess(cls) -> type[BatchInterface]: + """ + Select a batch system from the `QQ_BATCH_SYSTEM` environment + variable, falling back to `guess` when the variable is unset. + + Raises: + QQError: If the variable names an unknown system, or if no + available system can be guessed. + """ + name = os.environ.get(CFG.env_vars.batch_system) + if name: + logger.debug( + f"Using batch system name from an environment variable: {name}." + ) + return cls.from_str(name) + + return cls.guess() + + def obtain(cls, name: str | None) -> type[BatchInterface]: + """ + Obtain a batch system class by explicit name, environment + variable, or automatic detection - in that priority order. + + Args: + name: Optional batch system name. When `None`, delegates + to `from_env_var_or_guess`. + + Raises: + QQError: If the name is unknown or no system can be resolved. + """ + if name: + return cls.from_str(name) + + return cls.from_env_var_or_guess() + + class BatchInterface[ TBatchJob: BatchJobInterface = BatchJobInterface, TBatchQueue: BatchQueueInterface = BatchQueueInterface, TBatchNode: BatchNodeInterface = BatchNodeInterface, -](ABC): +](ABC, metaclass=_BatchMeta): """ Abstract base class for batch system integrations. diff --git a/src/qq_lib/batch/interface/meta.py b/src/qq_lib/batch/interface/meta.py deleted file mode 100644 index 886051b..0000000 --- a/src/qq_lib/batch/interface/meta.py +++ /dev/null @@ -1,137 +0,0 @@ -# Released under MIT License. -# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab - - -import os -from abc import ABCMeta - -from qq_lib.batch.interface import AnyBatchClass -from qq_lib.core.config import CFG -from qq_lib.core.error import QQError -from qq_lib.core.logger import get_logger - -from .interface import BatchInterface - -logger = get_logger(__name__) - - -class BatchMeta(ABCMeta): - """ - Metaclass for batch system classes. - """ - - # registry of supported batch systems - _registry: dict[str, type[BatchInterface]] = {} - - def __str__(cls: type[BatchInterface]) -> str: - """ - Get the string representation of the batch system class. - """ - return cls.env_name() - - @classmethod - def register_batch_system(cls, batch_cls: AnyBatchClass) -> None: - """ - Register a batch system class in the metaclass registry. - - Args: - batch_cls: Subclass of BatchInterface to register. - """ - cls._registry[batch_cls.env_name()] = batch_cls - - @classmethod - def from_str(mcs, name: str) -> type[BatchInterface]: - """ - Return the batch system class registered with the given name. - - Raises: - QQError: If no class is registered for the given name. - """ - try: - return mcs._registry[name] - except KeyError as e: - raise QQError(f"No batch system registered as '{name}'.") from e - - @classmethod - def guess(mcs) -> type[BatchInterface]: - """ - Attempt to select an appropriate batch system implementation. - - The method scans through all registered batch systems in the order - they were registered and returns the first one that reports itself - as available. - - Raises: - QQError: If no available batch system is found among the registered ones. - - Returns: - type[BatchInterface]: The first available batch system class. - """ - for BatchSystem in mcs._registry.values(): - if BatchSystem.is_available(): - logger.debug(f"Guessed batch system: {str(BatchSystem)}.") - return BatchSystem - - # raise error if there is no available batch system - raise QQError( - "Could not guess a batch system. No registered batch system available." - ) - - @classmethod - def from_env_var_or_guess(mcs) -> type[BatchInterface]: - """ - Select a batch system based on the environment variable or by guessing. - - This method first checks the `QQ_BATCH_SYSTEM` environment variable. If it is set, - the method returns the registered batch system class corresponding to its value. - If the variable is not set, it falls back to `guess` to select an available - batch system from the registered classes. - - Returns: - type[BatchInterface]: The selected batch system class. - - Raises: - QQError: If the environment variable is set to an unknown batch system name, - or if no available batch system can be guessed. - """ - name = os.environ.get(CFG.env_vars.batch_system) - if name: - logger.debug( - f"Using batch system name from an environment variable: {name}." - ) - return BatchMeta.from_str(name) - - return BatchMeta.guess() - - @classmethod - def obtain(mcs, name: str | None) -> type[BatchInterface]: - """ - Obtain a batch system class by name, environment variable, or guessing. - - Args: - name (str | None): Optional name of the batch system to obtain. - - If provided, returns the class registered under this name. - - If `None`, falls back to `from_env_var_or_guess` to determine - the batch system from the environment variable or by guessing. - - Returns: - type[BatchInterface]: The selected batch system class. - - Raises: - QQError: If `name` is provided but no batch system with that name is registered, - or if `name` is `None` and `from_env_var_or_guess` fails. - """ - if name: - return BatchMeta.from_str(name) - - return BatchMeta.from_env_var_or_guess() - - -def batch_system(cls): - """ - Class decorator to register a batch system class with the BatchMeta registry. - - Has to be added to every implementation of `BatchInterface`. - """ - BatchMeta.register_batch_system(cls) - return cls diff --git a/src/qq_lib/batch/pbs/pbs.py b/src/qq_lib/batch/pbs/pbs.py index ea93fcd..05efa3b 100644 --- a/src/qq_lib/batch/pbs/pbs.py +++ b/src/qq_lib/batch/pbs/pbs.py @@ -9,8 +9,7 @@ from collections.abc import Callable from pathlib import Path -from qq_lib.batch.interface import BatchInterface, BatchMeta -from qq_lib.batch.interface.meta import batch_system +from qq_lib.batch.interface import BatchInterface from qq_lib.batch.pbs.common import parse_multi_pbs_dump_to_dictionaries from qq_lib.batch.pbs.node import PBSNode from qq_lib.batch.pbs.queue import PBSQueue @@ -27,8 +26,7 @@ logger = get_logger(__name__) -@batch_system -class PBS(BatchInterface[PBSJob, PBSQueue, PBSNode], metaclass=BatchMeta): +class PBS(BatchInterface[PBSJob, PBSQueue, PBSNode]): """ Implementation of BatchInterface for PBS Pro batch system. """ diff --git a/src/qq_lib/batch/slurm/slurm.py b/src/qq_lib/batch/slurm/slurm.py index 9ec3a06..64d98cf 100644 --- a/src/qq_lib/batch/slurm/slurm.py +++ b/src/qq_lib/batch/slurm/slurm.py @@ -8,7 +8,6 @@ from pathlib import Path from qq_lib.batch.interface import BatchInterface -from qq_lib.batch.interface.meta import BatchMeta, batch_system from qq_lib.batch.pbs.pbs import PBS from qq_lib.core.config import CFG from qq_lib.core.error import QQError @@ -28,8 +27,7 @@ logger = get_logger(__name__) -@batch_system -class Slurm(BatchInterface[SlurmJob, SlurmQueue, SlurmNode], metaclass=BatchMeta): +class Slurm(BatchInterface[SlurmJob, SlurmQueue, SlurmNode]): """ Implementation of BatchInterface for Slurm batch system. """ diff --git a/src/qq_lib/batch/slurmit4i/slurm.py b/src/qq_lib/batch/slurmit4i/slurm.py index 199db21..c015910 100644 --- a/src/qq_lib/batch/slurmit4i/slurm.py +++ b/src/qq_lib/batch/slurmit4i/slurm.py @@ -8,7 +8,6 @@ from pathlib import Path from qq_lib.batch.interface import BatchInterface -from qq_lib.batch.interface.meta import BatchMeta, batch_system from qq_lib.batch.slurm import Slurm from qq_lib.batch.slurm.queue import SlurmQueue from qq_lib.core.common import equals_normalized @@ -20,8 +19,7 @@ logger = get_logger(__name__) -@batch_system -class SlurmIT4I(Slurm, metaclass=BatchMeta): +class SlurmIT4I(Slurm): """ Implementation of BatchInterface for Slurm on IT4I clusters. """ diff --git a/src/qq_lib/batch/slurmlumi/slurm.py b/src/qq_lib/batch/slurmlumi/slurm.py index 943700a..859d867 100644 --- a/src/qq_lib/batch/slurmlumi/slurm.py +++ b/src/qq_lib/batch/slurmlumi/slurm.py @@ -7,7 +7,6 @@ from pathlib import Path from typing import cast -from qq_lib.batch.interface.meta import BatchMeta, batch_system from qq_lib.batch.slurm import Slurm from qq_lib.batch.slurmit4i import SlurmIT4I from qq_lib.batch.slurmlumi.node import SlurmLumiNode @@ -20,8 +19,7 @@ logger = get_logger(__name__) -@batch_system -class SlurmLumi(SlurmIT4I, metaclass=BatchMeta): +class SlurmLumi(SlurmIT4I): """ Implementation of BatchInterface for Slurm on the LUMI supercomputer. """ diff --git a/src/qq_lib/cd/cli.py b/src/qq_lib/cd/cli.py index ae770ab..4ff76b4 100644 --- a/src/qq_lib/cd/cli.py +++ b/src/qq_lib/cd/cli.py @@ -7,7 +7,7 @@ import click -from qq_lib.batch.interface import BatchMeta +from qq_lib.batch.interface import BatchInterface from qq_lib.cd.cder import Cder from qq_lib.core.click_format import GNUHelpColorsCommand from qq_lib.core.config import CFG @@ -40,7 +40,7 @@ def cd(job: str) -> NoReturn: which then cds to this directory in the parent shell. """ try: - cder = Cder(BatchMeta.from_env_var_or_guess(), job) + cder = Cder(BatchInterface.from_env_var_or_guess(), job) print(cder.cd()) sys.exit(0) except QQError as e: diff --git a/src/qq_lib/core/common.py b/src/qq_lib/core/common.py index bdc9c85..f439d6f 100644 --- a/src/qq_lib/core/common.py +++ b/src/qq_lib/core/common.py @@ -156,12 +156,9 @@ def get_info_file_from_job_id(job_id: str) -> Path: the job does not exist or is not a qq job. """ - from qq_lib.batch.interface import ( - BatchJobInterface, - BatchMeta, - ) + from qq_lib.batch.interface import BatchInterface, BatchJobInterface - BatchSystem = BatchMeta.from_env_var_or_guess() + BatchSystem = BatchInterface.from_env_var_or_guess() job_info: BatchJobInterface = BatchSystem.get_batch_job(job_id) if job_info.is_empty(): @@ -719,11 +716,11 @@ def available_work_dirs() -> str: str: A comma-separated list of supported work directory types, each wrapped in quotes. """ - from qq_lib.batch.interface.meta import BatchMeta + from qq_lib.batch.interface import BatchInterface try: - batch_system = BatchMeta.from_env_var_or_guess() - work_dirs = batch_system.get_supported_work_dir_types() + BatchSystem = BatchInterface.from_env_var_or_guess() + work_dirs = BatchSystem.get_supported_work_dir_types() return ", ".join([f"'{work_dir_type}'" for work_dir_type in work_dirs]) except QQError: return "??? (no batch system detected)" diff --git a/src/qq_lib/info/informer.py b/src/qq_lib/info/informer.py index b98a414..9ed9f96 100644 --- a/src/qq_lib/info/informer.py +++ b/src/qq_lib/info/informer.py @@ -6,7 +6,6 @@ from typing import Self from qq_lib.batch.interface import BatchInterface, BatchJobInterface -from qq_lib.batch.interface.meta import BatchMeta from qq_lib.core.common import construct_info_file_path from qq_lib.core.error import QQError, QQJobMismatchError from qq_lib.core.logger import get_logger @@ -79,8 +78,8 @@ def from_job_id(cls, job_id: str) -> Self: QQError: If the job does not exist or is not a valid qq job. QQJobMismatchError: If the info file does not correspond to the job's ID. """ - batch_system = BatchMeta.from_env_var_or_guess() - batch_job: BatchJobInterface = batch_system.get_batch_job(job_id) + BatchSystem = BatchInterface.from_env_var_or_guess() + batch_job: BatchJobInterface = BatchSystem.get_batch_job(job_id) if batch_job.is_empty(): raise QQError(f"Job '{job_id}' does not exist.") diff --git a/src/qq_lib/jobs/cli.py b/src/qq_lib/jobs/cli.py index c823fda..cd3af93 100644 --- a/src/qq_lib/jobs/cli.py +++ b/src/qq_lib/jobs/cli.py @@ -9,7 +9,7 @@ import click from rich.console import Console -from qq_lib.batch.interface import BatchMeta +from qq_lib.batch.interface import BatchInterface from qq_lib.core.click_format import GNUHelpColorsCommand from qq_lib.core.common import translate_server from qq_lib.core.config import CFG @@ -54,7 +54,7 @@ @click.option("--yaml", is_flag=True, help="Output job metadata in YAML format.") def jobs(user: str, extra: bool, all: bool, server: str | None, yaml: bool) -> NoReturn: try: - batch_system = BatchMeta.from_env_var_or_guess() + BatchSystem = BatchInterface.from_env_var_or_guess() if not user: # use the current user, if `--user` is not specified user = getpass.getuser() @@ -63,16 +63,16 @@ def jobs(user: str, extra: bool, all: bool, server: str | None, yaml: bool) -> N server = translate_server(server) if all: - jobs = batch_system.get_batch_jobs(user, server) + jobs = BatchSystem.get_batch_jobs(user, server) else: - jobs = batch_system.get_unfinished_batch_jobs(user, server) + jobs = BatchSystem.get_unfinished_batch_jobs(user, server) if not jobs: logger.info("No jobs found.") sys.exit(0) - batch_system.sort_jobs(jobs) - presenter = JobsPresenter(batch_system, jobs, extra, all, server) + BatchSystem.sort_jobs(jobs) + presenter = JobsPresenter(BatchSystem, jobs, extra, all, server) if yaml: presenter.dump_yaml() else: diff --git a/src/qq_lib/killall/cli.py b/src/qq_lib/killall/cli.py index d7aee53..1a87c19 100644 --- a/src/qq_lib/killall/cli.py +++ b/src/qq_lib/killall/cli.py @@ -9,8 +9,8 @@ import click +from qq_lib.batch.interface import BatchInterface from qq_lib.batch.interface.job import BatchJobInterface -from qq_lib.batch.interface.meta import BatchMeta 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 @@ -49,7 +49,7 @@ def killall( yes: bool = False, force: bool = False, server: str | None = None ) -> NoReturn: try: - BatchSystem = BatchMeta.from_env_var_or_guess() + BatchSystem = BatchInterface.from_env_var_or_guess() if server: server = translate_server(server) diff --git a/src/qq_lib/nodes/cli.py b/src/qq_lib/nodes/cli.py index b80cac2..1f1db33 100644 --- a/src/qq_lib/nodes/cli.py +++ b/src/qq_lib/nodes/cli.py @@ -9,7 +9,7 @@ import click from rich.console import Console -from qq_lib.batch.interface.meta import BatchMeta +from qq_lib.batch.interface import BatchInterface from qq_lib.core.common import translate_server if TYPE_CHECKING: @@ -49,7 +49,7 @@ @click.option("--yaml", is_flag=True, help="Output node metadata in YAML format.") def nodes(all: bool, server: str | None, yaml: bool) -> NoReturn: try: - BatchSystem = BatchMeta.from_env_var_or_guess() + BatchSystem = BatchInterface.from_env_var_or_guess() if server: server = translate_server(server) diff --git a/src/qq_lib/properties/info.py b/src/qq_lib/properties/info.py index fa7024f..0c77c5c 100644 --- a/src/qq_lib/properties/info.py +++ b/src/qq_lib/properties/info.py @@ -22,7 +22,7 @@ import yaml -from qq_lib.batch.interface import AnyBatchClass, BatchMeta +from qq_lib.batch.interface import AnyBatchClass, BatchInterface from qq_lib.core.common import load_yaml_dumper, load_yaml_loader from qq_lib.core.config import CFG from qq_lib.core.error import QQError @@ -165,7 +165,7 @@ def from_file(cls, file: Path, host: str | None = None) -> Self: # remote file logger.debug(f"Loading qq info from '{file}' on '{host}'.") - BatchSystem = BatchMeta.from_env_var_or_guess() + BatchSystem = BatchInterface.from_env_var_or_guess() data: dict[str, object] = yaml.load( BatchSystem.read_remote_file(host, file), Loader=SafeLoader, @@ -373,7 +373,7 @@ def _from_dict(cls, data: dict[str, object]) -> Self: init_kwargs[name] = Resources(**value) # ty: ignore[invalid-argument-type] # convert the batch system elif f.type == AnyBatchClass and isinstance(value, str): - init_kwargs[name] = BatchMeta.from_str(value) + init_kwargs[name] = BatchInterface.from_str(value) # convert the job state elif f.type == NaiveState and isinstance(value, str): init_kwargs[name] = ( diff --git a/src/qq_lib/queues/cli.py b/src/qq_lib/queues/cli.py index 72d69e3..4b20f12 100644 --- a/src/qq_lib/queues/cli.py +++ b/src/qq_lib/queues/cli.py @@ -9,7 +9,7 @@ import click from rich.console import Console -from qq_lib.batch.interface.meta import BatchMeta +from qq_lib.batch.interface import BatchInterface from qq_lib.core.common import translate_server from qq_lib.core.config import CFG @@ -47,7 +47,7 @@ @click.option("--yaml", is_flag=True, help="Output queue metadata in YAML format.") def queues(all: bool, server: str | None, yaml: bool) -> NoReturn: try: - BatchSystem = BatchMeta.from_env_var_or_guess() + BatchSystem = BatchInterface.from_env_var_or_guess() if server: server = translate_server(server) diff --git a/src/qq_lib/run/runner.py b/src/qq_lib/run/runner.py index b41b546..e314d7f 100644 --- a/src/qq_lib/run/runner.py +++ b/src/qq_lib/run/runner.py @@ -15,7 +15,7 @@ import qq_lib from qq_lib.archive.archiver import Archiver -from qq_lib.batch.interface.meta import BatchMeta +from qq_lib.batch.interface import BatchInterface from qq_lib.core.common import construct_loop_job_name from qq_lib.core.config import CFG from qq_lib.core.error import ( @@ -71,7 +71,7 @@ def __init__(self, info_file: Path, host: str): # load the info file or raise a fatal qq error if this fails try: # get the batch system from the environment variable (or guess it) - self._batch_system = BatchMeta.from_env_var_or_guess() + self._batch_system = BatchInterface.from_env_var_or_guess() logger.debug(f"Batch system: {str(self._batch_system)}.") # get the id of the job from the batch system diff --git a/src/qq_lib/stat/cli.py b/src/qq_lib/stat/cli.py index 3970bc9..c6e8609 100644 --- a/src/qq_lib/stat/cli.py +++ b/src/qq_lib/stat/cli.py @@ -8,7 +8,7 @@ import click from rich.console import Console -from qq_lib.batch.interface import BatchMeta +from qq_lib.batch.interface import BatchInterface from qq_lib.core.click_format import GNUHelpColorsCommand from qq_lib.core.common import translate_server from qq_lib.core.config import CFG @@ -46,22 +46,22 @@ @click.option("--yaml", is_flag=True, help="Output job metadata in YAML format.") def stat(extra: bool, all: bool, server: str | None, yaml: bool) -> NoReturn: try: - batch_system = BatchMeta.from_env_var_or_guess() + BatchSystem = BatchInterface.from_env_var_or_guess() if server: server = translate_server(server) if all: - jobs = batch_system.get_all_batch_jobs(server) + jobs = BatchSystem.get_all_batch_jobs(server) else: - jobs = batch_system.get_all_unfinished_batch_jobs(server) + jobs = BatchSystem.get_all_unfinished_batch_jobs(server) if not jobs: logger.info("No jobs found.") sys.exit(0) - batch_system.sort_jobs(jobs) - presenter = JobsPresenter(batch_system, jobs, extra, all, server) + BatchSystem.sort_jobs(jobs) + presenter = JobsPresenter(BatchSystem, jobs, extra, all, server) if yaml: presenter.dump_yaml() else: diff --git a/src/qq_lib/submit/factory.py b/src/qq_lib/submit/factory.py index 627f3d6..4b1ea84 100644 --- a/src/qq_lib/submit/factory.py +++ b/src/qq_lib/submit/factory.py @@ -4,7 +4,7 @@ from dataclasses import fields from pathlib import Path -from qq_lib.batch.interface import BatchInterface, BatchMeta +from qq_lib.batch.interface import BatchInterface from qq_lib.core.common import split_files_list, translate_server from qq_lib.core.error import QQError from qq_lib.properties.depend import Depend @@ -90,8 +90,8 @@ def _get_batch_system(self) -> type[BatchInterface]: type[BatchInterface]: The selected batch system class. """ if batch_system := self._kwargs.get("batch_system"): - return BatchMeta.from_str(batch_system) - return self._parser.get_batch_system() or BatchMeta.from_env_var_or_guess() + return BatchInterface.from_str(batch_system) + return self._parser.get_batch_system() or BatchInterface.from_env_var_or_guess() def _get_job_type(self) -> JobType: """ diff --git a/src/qq_lib/submit/parser.py b/src/qq_lib/submit/parser.py index 685f9bf..6c4effc 100644 --- a/src/qq_lib/submit/parser.py +++ b/src/qq_lib/submit/parser.py @@ -8,7 +8,7 @@ from click import Parameter from click_option_group import GroupedOption -from qq_lib.batch.interface import BatchInterface, BatchMeta +from qq_lib.batch.interface import BatchInterface from qq_lib.core.common import split_files_list, to_snake_case from qq_lib.core.error import QQError from qq_lib.core.logger import get_logger @@ -118,7 +118,7 @@ def get_batch_system(self) -> type[BatchInterface] | None: type[BatchInterface] | None: The batch system class if specified, otherwise None. """ if (batch_system := self._options.get("batch_system")) is not None: - return BatchMeta.from_str(str(batch_system)) + return BatchInterface.from_str(str(batch_system)) return None diff --git a/tests/test_batch_interface.py b/tests/test_batch_interface.py index 42d1065..aaa33a8 100644 --- a/tests/test_batch_interface.py +++ b/tests/test_batch_interface.py @@ -9,8 +9,8 @@ import pytest -from qq_lib.batch.interface import BatchInterface, BatchJobInterface, BatchMeta -from qq_lib.batch.interface.interface import CFG +from qq_lib.batch.interface import BatchInterface, BatchJobInterface +from qq_lib.batch.interface.interface import CFG, _BatchMeta from qq_lib.batch.pbs import PBS from qq_lib.core.error import QQError @@ -57,99 +57,99 @@ def test_navigate_same_host_error(): def test_guess_pbs(): - BatchMeta._registry.clear() - BatchMeta.register_batch_system(PBS) + _BatchMeta._registry.clear() + _BatchMeta._registry[PBS.env_name()] = PBS with patch.object(PBS, "is_available", return_value=True): - assert BatchMeta.guess() is PBS + assert BatchInterface.guess() is PBS with ( patch.object(PBS, "is_available", return_value=False), pytest.raises(QQError, match="Could not guess a batch system"), ): - BatchMeta.guess() + BatchInterface.guess() def test_guess_empty_registry(): - BatchMeta._registry.clear() + _BatchMeta._registry.clear() with pytest.raises(QQError, match="Could not guess a batch system"): - BatchMeta.guess() + BatchInterface.guess() def test_from_str_success(): - BatchMeta._registry.clear() - BatchMeta.register_batch_system(PBS) + _BatchMeta._registry.clear() + _BatchMeta._registry[PBS.env_name()] = PBS - assert BatchMeta.from_str("PBS") is PBS + assert BatchInterface.from_str("PBS") is PBS def test_from_str_pbs_not_registered(): - BatchMeta._registry.clear() + _BatchMeta._registry.clear() with pytest.raises(QQError, match="No batch system registered"): - BatchMeta.from_str("PBS") + BatchInterface.from_str("PBS") def test_from_str_none_registered(): - BatchMeta._registry.clear() + _BatchMeta._registry.clear() with pytest.raises(QQError, match="No batch system registered"): - BatchMeta.from_str("PBS") + BatchInterface.from_str("PBS") def test_env_var_or_guess_from_env_var_returns_value(monkeypatch): - BatchMeta._registry.clear() - BatchMeta.register_batch_system(PBS) + _BatchMeta._registry.clear() + _BatchMeta._registry[PBS.env_name()] = PBS monkeypatch.setenv(CFG.env_vars.batch_system, "PBS") - assert BatchMeta.from_env_var_or_guess() is PBS + assert BatchInterface.from_env_var_or_guess() is PBS def test_env_var_or_guess_from_env_var_not_set_calls_guess(): - BatchMeta._registry.clear() - BatchMeta.register_batch_system(PBS) + _BatchMeta._registry.clear() + _BatchMeta._registry[PBS.env_name()] = PBS if CFG.env_vars.batch_system in os.environ: del os.environ[CFG.env_vars.batch_system] with ( patch.object(PBS, "is_available", return_value=True), ): - assert BatchMeta.from_env_var_or_guess() is PBS + assert BatchInterface.from_env_var_or_guess() is PBS def test_from_env_var_not_set_calls_guess(): - BatchMeta._registry.clear() + _BatchMeta._registry.clear() if CFG.env_vars.batch_system in os.environ: del os.environ[CFG.env_vars.batch_system] with pytest.raises(QQError, match="Could not guess a batch system"): - BatchMeta.from_env_var_or_guess() + BatchInterface.from_env_var_or_guess() def test_obtain_with_name_registered(): - BatchMeta._registry.clear() - BatchMeta.register_batch_system(PBS) + _BatchMeta._registry.clear() + _BatchMeta._registry[PBS.env_name()] = PBS - assert BatchMeta.obtain("PBS") is PBS + assert BatchInterface.obtain("PBS") is PBS def test_obtain_with_name_not_registered(): - BatchMeta._registry.clear() + _BatchMeta._registry.clear() with pytest.raises(QQError, match="No batch system registered"): - BatchMeta.obtain("PBS") + BatchInterface.obtain("PBS") def test_obtain_without_name_env_var(monkeypatch): - BatchMeta._registry.clear() - BatchMeta.register_batch_system(PBS) + _BatchMeta._registry.clear() + _BatchMeta._registry[PBS.env_name()] = PBS monkeypatch.setenv(CFG.env_vars.batch_system, "PBS") - assert BatchMeta.obtain(None) is PBS + assert BatchInterface.obtain(None) is PBS def test_obtain_without_name_and_guess_fails(): - BatchMeta._registry.clear() + _BatchMeta._registry.clear() if CFG.env_vars.batch_system in os.environ: del os.environ[CFG.env_vars.batch_system] @@ -157,7 +157,7 @@ def test_obtain_without_name_and_guess_fails(): patch.object(PBS, "is_available", return_value=False), pytest.raises(QQError, match="Could not guess a batch system"), ): - BatchMeta.obtain(None) + BatchInterface.obtain(None) def test_sync_with_exclusions_copies_new_files(tmp_path): diff --git a/tests/test_cd_cli.py b/tests/test_cd_cli.py index 225c0fc..b8b6929 100644 --- a/tests/test_cd_cli.py +++ b/tests/test_cd_cli.py @@ -6,16 +6,16 @@ import pytest from click.testing import CliRunner -from qq_lib.batch.interface import BatchMeta -from qq_lib.batch.interface.interface import CFG +from qq_lib.batch.interface import BatchInterface +from qq_lib.batch.interface.interface import CFG, _BatchMeta from qq_lib.batch.pbs import PBS, PBSJob from qq_lib.cd.cli import cd @pytest.fixture(autouse=True) def register(): - BatchMeta._registry.clear() - BatchMeta.register_batch_system(PBS) + _BatchMeta._registry.clear() + _BatchMeta._registry[PBS.env_name()] = PBS def _make_jobinfo_with_info(info: dict[str, str]) -> PBSJob: @@ -31,7 +31,7 @@ def test_cd_command_success_pbs_o_workdir(): job_info = _make_jobinfo_with_info({"Variable_List": env_vars}) with ( - patch.object(BatchMeta, "from_env_var_or_guess", return_value=PBS), + patch.object(BatchInterface, "from_env_var_or_guess", return_value=PBS), patch.object(PBS, "get_batch_job", return_value=job_info), ): result = runner.invoke(cd, ["1234"]) @@ -45,7 +45,7 @@ def test_cd_command_success_input_dir(): job_info = _make_jobinfo_with_info({"Variable_List": env_vars}) with ( - patch.object(BatchMeta, "from_env_var_or_guess", return_value=PBS), + patch.object(BatchInterface, "from_env_var_or_guess", return_value=PBS), patch.object(PBS, "get_batch_job", return_value=job_info), ): result = runner.invoke(cd, ["1234"]) @@ -58,7 +58,7 @@ def test_cd_command_job_does_not_exist(): job_info_empty = _make_jobinfo_with_info({}) with ( - patch.object(BatchMeta, "from_env_var_or_guess", return_value=PBS), + patch.object(BatchInterface, "from_env_var_or_guess", return_value=PBS), patch.object(PBS, "get_batch_job", return_value=job_info_empty), ): result = runner.invoke(cd, ["1234"]) diff --git a/tests/test_core_common.py b/tests/test_core_common.py index 9b2ccf1..669f303 100644 --- a/tests/test_core_common.py +++ b/tests/test_core_common.py @@ -12,7 +12,7 @@ import pytest from rich.console import Console -from qq_lib.batch.interface.meta import BatchMeta +from qq_lib.batch.interface import BatchInterface from qq_lib.batch.pbs import PBS, PBSJob from qq_lib.core.common import ( CFG, @@ -747,7 +747,7 @@ def _make_jobinfo_with_info(info: dict[str, str]) -> PBSJob: def test_get_info_file_from_job_id_success(): with ( - patch.object(BatchMeta, "from_env_var_or_guess", return_value=PBS), + patch.object(BatchInterface, "from_env_var_or_guess", return_value=PBS), patch.object( PBS, "get_batch_job", @@ -763,7 +763,7 @@ def test_get_info_file_from_job_id_success(): def test_get_info_file_from_job_id_no_info(): with ( - patch.object(BatchMeta, "from_env_var_or_guess", return_value=PBS), + patch.object(BatchInterface, "from_env_var_or_guess", return_value=PBS), patch.object( PBS, "get_batch_job", @@ -780,7 +780,7 @@ def test_get_info_file_from_job_id_no_info(): def test_get_info_file_from_job_id_nonexistent_job(): with ( - patch.object(BatchMeta, "from_env_var_or_guess", return_value=PBS), + patch.object(BatchInterface, "from_env_var_or_guess", return_value=PBS), patch.object( PBS, "get_batch_job", @@ -1071,7 +1071,7 @@ def test_available_work_dirs_returns_joined_list(): mock_batch_system.get_supported_work_dir_types.return_value = ["a", "b"] with patch.object( - BatchMeta, "from_env_var_or_guess", return_value=mock_batch_system + BatchInterface, "from_env_var_or_guess", return_value=mock_batch_system ): expected = "'a', 'b'" @@ -1079,7 +1079,7 @@ def test_available_work_dirs_returns_joined_list(): def test_available_work_dirs_returns_placeholder_on_error(): - with patch.object(BatchMeta, "from_env_var_or_guess", side_effect=QQError): + with patch.object(BatchInterface, "from_env_var_or_guess", side_effect=QQError): assert available_work_dirs() == "??? (no batch system detected)" diff --git a/tests/test_info_informer.py b/tests/test_info_informer.py index 9cad8ac..c03202b 100644 --- a/tests/test_info_informer.py +++ b/tests/test_info_informer.py @@ -485,7 +485,7 @@ def test_informer_from_job_id_raises_when_empty(): with ( patch( - "qq_lib.info.informer.BatchMeta.from_env_var_or_guess", + "qq_lib.info.informer.BatchInterface.from_env_var_or_guess", return_value=batch_system, ), pytest.raises(QQError, match="Job '123' does not exist."), @@ -503,7 +503,7 @@ def test_informer_from_job_id_returns_informer_when_valid(): with ( patch( - "qq_lib.info.informer.BatchMeta.from_env_var_or_guess", + "qq_lib.info.informer.BatchInterface.from_env_var_or_guess", return_value=batch_system, ), patch( diff --git a/tests/test_jobs_cli.py b/tests/test_jobs_cli.py index cdd4e54..439385f 100644 --- a/tests/test_jobs_cli.py +++ b/tests/test_jobs_cli.py @@ -7,7 +7,7 @@ import pytest from click.testing import CliRunner -from qq_lib.batch.interface import BatchMeta +from qq_lib.batch.interface import BatchInterface from qq_lib.batch.pbs import PBS, PBSJob from qq_lib.batch.pbs.common import parse_multi_pbs_dump_to_dictionaries from qq_lib.jobs.cli import jobs @@ -78,7 +78,7 @@ def test_jobs_command_unfinished_shows_jobs(parsed_jobs): runner = CliRunner() with ( - patch.object(BatchMeta, "from_env_var_or_guess", return_value=PBS), + patch.object(BatchInterface, "from_env_var_or_guess", return_value=PBS), patch.object( PBS, "get_unfinished_batch_jobs", return_value=parsed_jobs ) as mock_get_jobs, @@ -107,7 +107,7 @@ def test_jobs_command_unfinished_shows_jobs_with_server(parsed_jobs): runner = CliRunner() with ( - patch.object(BatchMeta, "from_env_var_or_guess", return_value=PBS), + patch.object(BatchInterface, "from_env_var_or_guess", return_value=PBS), patch.object( PBS, "get_unfinished_batch_jobs", return_value=parsed_jobs ) as mock_get_jobs, @@ -136,7 +136,7 @@ def test_jobs_command_all_flag_shows_all_jobs(parsed_jobs): runner = CliRunner() with ( - patch.object(BatchMeta, "from_env_var_or_guess", return_value=PBS), + patch.object(BatchInterface, "from_env_var_or_guess", return_value=PBS), patch.object(PBS, "get_batch_jobs", return_value=parsed_jobs) as mock_get_jobs, patch.object( PBS, @@ -163,7 +163,7 @@ def test_jobs_command_all_flag_shows_all_jobs_with_server(parsed_jobs): runner = CliRunner() with ( - patch.object(BatchMeta, "from_env_var_or_guess", return_value=PBS), + patch.object(BatchInterface, "from_env_var_or_guess", return_value=PBS), patch.object(PBS, "get_batch_jobs", return_value=parsed_jobs) as mock_get_jobs, patch.object( PBS, @@ -190,7 +190,7 @@ def test_jobs_command_yaml_flag_outputs_yaml(parsed_jobs): runner = CliRunner() with ( - patch.object(BatchMeta, "from_env_var_or_guess", return_value=PBS), + patch.object(BatchInterface, "from_env_var_or_guess", return_value=PBS), patch.object(PBS, "get_unfinished_batch_jobs", return_value=parsed_jobs), patch.object( PBS, @@ -217,7 +217,7 @@ def test_jobs_command_no_jobs(): runner = CliRunner() with ( - patch.object(BatchMeta, "from_env_var_or_guess", return_value=PBS), + patch.object(BatchInterface, "from_env_var_or_guess", return_value=PBS), patch.object(PBS, "get_unfinished_batch_jobs", return_value=[]), patch.object( PBS, diff --git a/tests/test_killall_cli.py b/tests/test_killall_cli.py index 002cea5..0e9ecc1 100644 --- a/tests/test_killall_cli.py +++ b/tests/test_killall_cli.py @@ -43,7 +43,9 @@ def test_informers_from_jobs_no_jobs(): def test_killall_no_jobs_exits_zero(): runner = CliRunner() with ( - patch("qq_lib.killall.cli.BatchMeta.from_env_var_or_guess") as batch_meta_mock, + 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 @@ -65,7 +67,9 @@ def test_killall_jobs_but_no_info_files_exits_zero(): job_mock = MagicMock(spec=BatchJobInterface) with ( - patch("qq_lib.killall.cli.BatchMeta.from_env_var_or_guess") as batch_meta_mock, + 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, ): @@ -91,7 +95,9 @@ def test_killall_yes_flag_invokes_repeater(): job_mock = MagicMock(spec=BatchJobInterface) with ( - patch("qq_lib.killall.cli.BatchMeta.from_env_var_or_guess") as batch_meta_mock, + 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 @@ -128,7 +134,9 @@ def test_killall_force_flag_invokes_repeater(): job_mock = MagicMock(spec=BatchJobInterface) with ( - patch("qq_lib.killall.cli.BatchMeta.from_env_var_or_guess") as batch_meta_mock, + 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 @@ -163,7 +171,9 @@ def test_killall_user_prompt_yes(monkeypatch): job_mock = MagicMock(spec=BatchJobInterface) with ( - patch("qq_lib.killall.cli.BatchMeta.from_env_var_or_guess") as batch_meta_mock, + 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 @@ -197,7 +207,9 @@ def test_killall_user_prompt_no(monkeypatch): job_mock = MagicMock(spec=BatchJobInterface) with ( - patch("qq_lib.killall.cli.BatchMeta.from_env_var_or_guess") as batch_meta_mock, + 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, ): @@ -216,7 +228,9 @@ def test_killall_user_prompt_no(monkeypatch): def test_killall_with_full_server_name_forwards_server(): runner = CliRunner() with ( - patch("qq_lib.killall.cli.BatchMeta.from_env_var_or_guess") as batch_meta_mock, + 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 @@ -231,7 +245,9 @@ def test_killall_with_full_server_name_forwards_server(): def test_killall_with_server_shortcut_translates_and_forwards_server(): runner = CliRunner() with ( - patch("qq_lib.killall.cli.BatchMeta.from_env_var_or_guess") as batch_meta_mock, + 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 @@ -248,7 +264,9 @@ def test_killall_qqerror_in_main_loop_exits_91(): runner = CliRunner() with ( - patch("qq_lib.killall.cli.BatchMeta.from_env_var_or_guess") as batch_meta_mock, + 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"), @@ -269,7 +287,9 @@ def raise_exception(*_args, **_kwargs): raise RuntimeError("unexpected") with ( - patch("qq_lib.killall.cli.BatchMeta.from_env_var_or_guess") as batch_meta_mock, + 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"), diff --git a/tests/test_nodes_cli.py b/tests/test_nodes_cli.py index 31ce66e..05e7f9d 100644 --- a/tests/test_nodes_cli.py +++ b/tests/test_nodes_cli.py @@ -16,7 +16,7 @@ def test_nodes_command_prints_available_nodes(): mock_node.is_available_to_user.return_value = True with ( - patch("qq_lib.nodes.cli.BatchMeta.from_env_var_or_guess") as mock_meta, + patch("qq_lib.nodes.cli.BatchInterface.from_env_var_or_guess") as mock_meta, patch("qq_lib.nodes.cli.NodesPresenter") as mock_presenter_cls, patch("qq_lib.nodes.cli.Console"), patch("qq_lib.nodes.cli.getpass.getuser", return_value="user"), @@ -43,7 +43,7 @@ def test_nodes_command_prints_available_nodes_with_server(): mock_node.is_available_to_user.return_value = True with ( - patch("qq_lib.nodes.cli.BatchMeta.from_env_var_or_guess") as mock_meta, + patch("qq_lib.nodes.cli.BatchInterface.from_env_var_or_guess") as mock_meta, patch("qq_lib.nodes.cli.NodesPresenter") as mock_presenter_cls, patch("qq_lib.nodes.cli.Console"), patch("qq_lib.nodes.cli.getpass.getuser", return_value="user"), @@ -69,7 +69,7 @@ def test_nodes_command_prints_all_nodes_with_flag(): mock_node = MagicMock() with ( - patch("qq_lib.nodes.cli.BatchMeta.from_env_var_or_guess") as mock_meta, + patch("qq_lib.nodes.cli.BatchInterface.from_env_var_or_guess") as mock_meta, patch("qq_lib.nodes.cli.NodesPresenter") as mock_presenter_cls, patch("qq_lib.nodes.cli.Console"), patch("qq_lib.nodes.cli.getpass.getuser", return_value="testuser"), @@ -95,7 +95,7 @@ def test_nodes_command_prints_all_nodes_with_flag_with_server(): mock_node = MagicMock() with ( - patch("qq_lib.nodes.cli.BatchMeta.from_env_var_or_guess") as mock_meta, + patch("qq_lib.nodes.cli.BatchInterface.from_env_var_or_guess") as mock_meta, patch("qq_lib.nodes.cli.NodesPresenter") as mock_presenter_cls, patch("qq_lib.nodes.cli.Console"), patch("qq_lib.nodes.cli.getpass.getuser", return_value="testuser"), @@ -122,7 +122,7 @@ def test_nodes_command_outputs_yaml_when_flag_set(): mock_node.is_available_to_user.return_value = True with ( - patch("qq_lib.nodes.cli.BatchMeta.from_env_var_or_guess") as mock_meta, + patch("qq_lib.nodes.cli.BatchInterface.from_env_var_or_guess") as mock_meta, patch("qq_lib.nodes.cli.NodesPresenter") as mock_presenter_cls, patch("qq_lib.nodes.cli.getpass.getuser", return_value="testuser"), ): @@ -147,7 +147,7 @@ def test_nodes_command_handles_qqerror_and_exits_91(): with ( patch( - "qq_lib.nodes.cli.BatchMeta.from_env_var_or_guess", + "qq_lib.nodes.cli.BatchInterface.from_env_var_or_guess", side_effect=QQError("error"), ), patch("qq_lib.nodes.cli.logger") as mock_logger, @@ -163,7 +163,7 @@ def test_nodes_command_handles_unexpected_exception_and_exits_99(): with ( patch( - "qq_lib.nodes.cli.BatchMeta.from_env_var_or_guess", + "qq_lib.nodes.cli.BatchInterface.from_env_var_or_guess", side_effect=RuntimeError("fatal"), ), patch("qq_lib.nodes.cli.logger") as mock_logger, diff --git a/tests/test_properties_info.py b/tests/test_properties_info.py index 06fa532..ad13aba 100644 --- a/tests/test_properties_info.py +++ b/tests/test_properties_info.py @@ -9,7 +9,7 @@ import pytest import yaml -from qq_lib.batch.interface import BatchMeta +from qq_lib.batch.interface.interface import _BatchMeta from qq_lib.batch.pbs import PBS from qq_lib.batch.slurmit4i import SlurmIT4I from qq_lib.core.error import QQError @@ -22,7 +22,7 @@ @pytest.fixture(autouse=True) def register(): - BatchMeta.register_batch_system(PBS) + _BatchMeta._registry[PBS.env_name()] = PBS @pytest.fixture diff --git a/tests/test_queues_cli.py b/tests/test_queues_cli.py index 24ccbd9..47268ea 100644 --- a/tests/test_queues_cli.py +++ b/tests/test_queues_cli.py @@ -16,7 +16,7 @@ def test_queues_command_prints_available_queues(): mock_queue.is_available_to_user.return_value = True with ( - patch("qq_lib.queues.cli.BatchMeta.from_env_var_or_guess") as mock_meta, + patch("qq_lib.queues.cli.BatchInterface.from_env_var_or_guess") as mock_meta, patch("qq_lib.queues.cli.QueuesPresenter") as mock_presenter_cls, patch("qq_lib.queues.cli.Console"), patch("qq_lib.queues.cli.getpass.getuser", return_value="user"), @@ -43,7 +43,7 @@ def test_queues_command_prints_available_queues_with_server(): mock_queue.is_available_to_user.return_value = True with ( - patch("qq_lib.queues.cli.BatchMeta.from_env_var_or_guess") as mock_meta, + patch("qq_lib.queues.cli.BatchInterface.from_env_var_or_guess") as mock_meta, patch("qq_lib.queues.cli.QueuesPresenter") as mock_presenter_cls, patch("qq_lib.queues.cli.Console"), patch("qq_lib.queues.cli.getpass.getuser", return_value="user"), @@ -69,7 +69,7 @@ def test_queues_command_prints_all_queues_with_flag(): mock_queue = MagicMock() with ( - patch("qq_lib.queues.cli.BatchMeta.from_env_var_or_guess") as mock_meta, + patch("qq_lib.queues.cli.BatchInterface.from_env_var_or_guess") as mock_meta, patch("qq_lib.queues.cli.QueuesPresenter") as mock_presenter_cls, patch("qq_lib.queues.cli.Console"), patch("qq_lib.queues.cli.getpass.getuser", return_value="testuser"), @@ -93,7 +93,7 @@ def test_queues_command_prints_all_queues_with_flag_with_server(): mock_queue = MagicMock() with ( - patch("qq_lib.queues.cli.BatchMeta.from_env_var_or_guess") as mock_meta, + patch("qq_lib.queues.cli.BatchInterface.from_env_var_or_guess") as mock_meta, patch("qq_lib.queues.cli.QueuesPresenter") as mock_presenter_cls, patch("qq_lib.queues.cli.Console"), patch("qq_lib.queues.cli.getpass.getuser", return_value="testuser"), @@ -118,7 +118,7 @@ def test_queues_command_outputs_yaml_when_flag_set(): mock_queue.is_available_to_user.return_value = True with ( - patch("qq_lib.queues.cli.BatchMeta.from_env_var_or_guess") as mock_meta, + patch("qq_lib.queues.cli.BatchInterface.from_env_var_or_guess") as mock_meta, patch("qq_lib.queues.cli.QueuesPresenter") as mock_presenter_cls, patch("qq_lib.queues.cli.getpass.getuser", return_value="testuser"), ): @@ -140,7 +140,7 @@ def test_queues_command_handles_qqerror_and_exits_91(): with ( patch( - "qq_lib.queues.cli.BatchMeta.from_env_var_or_guess", + "qq_lib.queues.cli.BatchInterface.from_env_var_or_guess", side_effect=QQError("error"), ), patch("qq_lib.queues.cli.logger") as mock_logger, @@ -156,7 +156,7 @@ def test_queues_command_handles_unexpected_exception_and_exits_99(): with ( patch( - "qq_lib.queues.cli.BatchMeta.from_env_var_or_guess", + "qq_lib.queues.cli.BatchInterface.from_env_var_or_guess", side_effect=RuntimeError("fatal"), ), patch("qq_lib.queues.cli.logger") as mock_logger, diff --git a/tests/test_run_runner.py b/tests/test_run_runner.py index 70ae206..8894312 100644 --- a/tests/test_run_runner.py +++ b/tests/test_run_runner.py @@ -30,12 +30,12 @@ def test_runner_init_success(): "qq_lib.run.runner.socket.getfqdn", return_value="mockhost" ) as mock_socket, patch("qq_lib.run.runner.qq_lib.__version__", "1.0.0"), - patch("qq_lib.run.runner.BatchMeta.from_env_var_or_guess") as mock_batchmeta, + patch("qq_lib.run.runner.BatchInterface.from_env_var_or_guess") as mock_batch, patch("qq_lib.run.runner.Retryer") as mock_retryer, ): batch = MagicMock() batch.get_job_id.return_value = "12345" - mock_batchmeta.return_value = batch + mock_batch.return_value = batch informer = MagicMock() informer.matches_job.return_value = True @@ -53,7 +53,7 @@ def test_runner_init_success(): runner = Runner(Path("job.qqinfo"), "input_host") mock_signal.assert_called_once() - mock_batchmeta.assert_called_once() + mock_batch.assert_called_once() mock_retryer.assert_called_once() retryer.run.assert_called_once() mock_logger.info.assert_called_once() @@ -72,7 +72,7 @@ def test_runner_init_success(): def test_runner_init_raises_when_get_job_id_missing(): with ( patch("qq_lib.run.runner.signal.signal"), - patch("qq_lib.run.runner.BatchMeta.from_env_var_or_guess") as mock_meta, + patch("qq_lib.run.runner.BatchInterface.from_env_var_or_guess") as mock_meta, ): batch = MagicMock() batch.get_job_id.return_value = None @@ -89,7 +89,7 @@ def test_runner_init_raises_on_batchmeta_failure(): with ( patch("qq_lib.run.runner.signal.signal"), patch( - "qq_lib.run.runner.BatchMeta.from_env_var_or_guess", + "qq_lib.run.runner.BatchInterface.from_env_var_or_guess", side_effect=Exception("boom"), ), pytest.raises(QQRunFatalError, match="Unable to load valid qq info file"), @@ -100,7 +100,9 @@ def test_runner_init_raises_on_batchmeta_failure(): def test_runner_init_raises_on_job_mismatch(): with ( patch("qq_lib.run.runner.signal.signal"), - patch("qq_lib.run.runner.BatchMeta.from_env_var_or_guess") as mock_batchmeta, + patch( + "qq_lib.run.runner.BatchInterface.from_env_var_or_guess" + ) as mock_batchmeta, patch("qq_lib.run.runner.Retryer") as mock_retryer, ): batch = MagicMock() @@ -127,7 +129,9 @@ def test_runner_init_raises_on_job_mismatch(): def test_runner_init_raises_on_batch_system_mismatch(): with ( patch("qq_lib.run.runner.signal.signal"), - patch("qq_lib.run.runner.BatchMeta.from_env_var_or_guess") as mock_batchmeta, + patch( + "qq_lib.run.runner.BatchInterface.from_env_var_or_guess" + ) as mock_batchmeta, patch("qq_lib.run.runner.Retryer") as mock_retryer, ): batch = MagicMock() @@ -154,7 +158,9 @@ def test_runner_init_raises_on_batch_system_mismatch(): def test_runner_init_creates_archiver_when_loop_info_present(): with ( patch("qq_lib.run.runner.signal.signal"), - patch("qq_lib.run.runner.BatchMeta.from_env_var_or_guess") as mock_batchmeta, + patch( + "qq_lib.run.runner.BatchInterface.from_env_var_or_guess" + ) as mock_batchmeta, patch("qq_lib.run.runner.Retryer") as mock_retryer, patch("qq_lib.run.runner.Archiver") as mock_archiver, ): diff --git a/tests/test_stat_cli.py b/tests/test_stat_cli.py index 1e68245..cd15e72 100644 --- a/tests/test_stat_cli.py +++ b/tests/test_stat_cli.py @@ -7,7 +7,7 @@ import pytest from click.testing import CliRunner -from qq_lib.batch.interface import BatchMeta +from qq_lib.batch.interface import BatchInterface from qq_lib.batch.pbs import PBS, PBSJob from qq_lib.batch.pbs.common import parse_multi_pbs_dump_to_dictionaries from qq_lib.jobs.presenter import JobsPresenter @@ -78,7 +78,7 @@ def test_stat_command_unfinished_shows_jobs(parsed_jobs): runner = CliRunner() with ( - patch.object(BatchMeta, "from_env_var_or_guess", return_value=PBS), + patch.object(BatchInterface, "from_env_var_or_guess", return_value=PBS), patch.object( PBS, "get_all_unfinished_batch_jobs", return_value=parsed_jobs ) as mock_get_jobs, @@ -107,7 +107,7 @@ def test_stat_command_unfinished_shows_jobs_with_server(parsed_jobs): runner = CliRunner() with ( - patch.object(BatchMeta, "from_env_var_or_guess", return_value=PBS), + patch.object(BatchInterface, "from_env_var_or_guess", return_value=PBS), patch.object( PBS, "get_all_unfinished_batch_jobs", return_value=parsed_jobs ) as mock_get_jobs, @@ -136,7 +136,7 @@ def test_stat_command_all_flag_shows_all_jobs(parsed_jobs): runner = CliRunner() with ( - patch.object(BatchMeta, "from_env_var_or_guess", return_value=PBS), + patch.object(BatchInterface, "from_env_var_or_guess", return_value=PBS), patch.object( PBS, "get_all_batch_jobs", return_value=parsed_jobs ) as mock_get_jobs, @@ -164,7 +164,7 @@ def test_stat_command_all_flag_shows_all_jobs_with_server(parsed_jobs): runner = CliRunner() with ( - patch.object(BatchMeta, "from_env_var_or_guess", return_value=PBS), + patch.object(BatchInterface, "from_env_var_or_guess", return_value=PBS), patch.object( PBS, "get_all_batch_jobs", return_value=parsed_jobs ) as mock_get_jobs, @@ -192,7 +192,7 @@ def test_stat_command_yaml_flag_outputs_yaml(parsed_jobs): runner = CliRunner() with ( - patch.object(BatchMeta, "from_env_var_or_guess", return_value=PBS), + patch.object(BatchInterface, "from_env_var_or_guess", return_value=PBS), patch.object(PBS, "get_all_unfinished_batch_jobs", return_value=parsed_jobs), patch.object( PBS, @@ -216,7 +216,7 @@ def test_stat_command_no_jobs(): runner = CliRunner() with ( - patch.object(BatchMeta, "from_env_var_or_guess", return_value=PBS), + patch.object(BatchInterface, "from_env_var_or_guess", return_value=PBS), patch.object(PBS, "get_all_unfinished_batch_jobs", return_value=[]), patch.object( PBS, diff --git a/tests/test_submit_factory.py b/tests/test_submit_factory.py index ccff0cd..40871f0 100644 --- a/tests/test_submit_factory.py +++ b/tests/test_submit_factory.py @@ -6,7 +6,6 @@ import pytest -from qq_lib.batch.interface import BatchMeta from qq_lib.batch.interface.interface import BatchInterface from qq_lib.core.error import QQError from qq_lib.properties.depend import Depend @@ -434,7 +433,9 @@ def test_submitter_factory_get_batch_system_uses_cli_over_parser_and_env(): factory._kwargs = {"batch_system": "PBS"} mock_class = MagicMock(spec=BatchInterface) - with patch.object(BatchMeta, "from_str", return_value=mock_class) as mock_from_str: + with patch.object( + BatchInterface, "from_str", return_value=mock_class + ) as mock_from_str: result = factory._get_batch_system() mock_from_str.assert_called_once_with("PBS") @@ -464,7 +465,7 @@ def test_submitter_factory_get_batch_system_uses_env_guess_if_no_cli_or_parser() mock_guess = MagicMock(spec=BatchInterface) with patch.object( - BatchMeta, "from_env_var_or_guess", return_value=mock_guess + BatchInterface, "from_env_var_or_guess", return_value=mock_guess ) as mock_method: result = factory._get_batch_system() diff --git a/tests/test_submit_parser.py b/tests/test_submit_parser.py index d178632..057aa3f 100644 --- a/tests/test_submit_parser.py +++ b/tests/test_submit_parser.py @@ -10,7 +10,6 @@ from click_option_group import GroupedOption from qq_lib.batch.interface.interface import BatchInterface -from qq_lib.batch.interface.meta import BatchMeta from qq_lib.batch.pbs import PBS from qq_lib.core.error import QQError from qq_lib.properties.depend import Depend @@ -324,7 +323,9 @@ def test_parser_get_batch_system_value(): mock_class = MagicMock(spec=BatchInterface) - with patch.object(BatchMeta, "from_str", return_value=mock_class) as mock_from_str: + with patch.object( + BatchInterface, "from_str", return_value=mock_class + ) as mock_from_str: result = parser.get_batch_system() mock_from_str.assert_called_once_with("PBS") @@ -552,9 +553,9 @@ def test_parser_parse_integration_skips_empty_lines(temp_script_file): def test_parser_parse_integration_skips_empty_lines_at_start(temp_script_file): tmp_file, path = temp_script_file tmp_file.write("""#!/usr/bin/env -S qq run - - + + # qq ncpus 8 @@ -686,7 +687,7 @@ def test_parser_integration(): run_random_program path/to/random/script # qq ngpus 3 # the above line should not be parsed - + # qq this line should definitely not be parsed # qq mem 16gb exit 0 From 3c492b2cf52ba9a6625f2d86fe92100e7b0546b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ladislav=20Barto=C5=A1?= Date: Mon, 4 May 2026 22:13:37 +0200 Subject: [PATCH 10/43] Batch systems registration --- src/qq_lib/batch/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/qq_lib/batch/__init__.py b/src/qq_lib/batch/__init__.py index 8f6feba..e254456 100644 --- a/src/qq_lib/batch/__init__.py +++ b/src/qq_lib/batch/__init__.py @@ -9,3 +9,11 @@ together with the concrete backends for PBS, Slurm, and site-specific Slurm variants. """ + +# import so that these batch systems are available but do not export them from here +from .pbs import PBS as _PBS +from .slurm import Slurm as _Slurm +from .slurmit4i import SlurmIT4I as _SlurmIT4I +from .slurmlumi import SlurmLumi as _SlurmLumi + +_PBS, _Slurm, _SlurmIT4I, _SlurmLumi From 777fd462a7fc5b0b56c0b8643da7c87b00794462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ladislav=20Barto=C5=A1?= Date: Mon, 4 May 2026 22:15:17 +0200 Subject: [PATCH 11/43] Documentation for the batch interface module --- src/qq_lib/batch/interface/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/qq_lib/batch/interface/__init__.py b/src/qq_lib/batch/interface/__init__.py index 3477ae4..33bd6c0 100644 --- a/src/qq_lib/batch/interface/__init__.py +++ b/src/qq_lib/batch/interface/__init__.py @@ -18,10 +18,7 @@ and allow qq to present consistent job/queue/node information regardless of scheduler differences. -- `BatchMeta`: a metaclass that registers available batch-system backends - and provides mechanisms for selecting one from environment variables or by - probing system availability. The `@batch_system` decorator registers - implementations automatically. +- `AnyBatchClass`: a type alias for batch system implementations. """ from .interface import AnyBatchClass, BatchInterface From d53b346792ce0eb4b83ba385a39973dff6c12bf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ladislav=20Barto=C5=A1?= Date: Mon, 4 May 2026 22:29:18 +0200 Subject: [PATCH 12/43] Updated python banner --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5c01eda..1cff6eb 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # qq logo : A friendly interface to batch processing -[![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/) +[![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/) [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) [![CI](https://github.com/ladme/qq/actions/workflows/ci.yml/badge.svg)](https://github.com/ladme/qq/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/ladme/qq/branch/main/graph/badge.svg)](https://codecov.io/gh/ladme/qq) @@ -9,4 +9,4 @@ Read the [qq manual](https://vachalab.github.io/qq-manual) for more information. -**Designed for the specific needs of the [RoVa Research Group](https://vacha.ceitec.cz/).** \ No newline at end of file +**Designed for the specific needs of the [RoVa Research Group](https://vacha.ceitec.cz/).** From c71bc6541ad7fdf7e660cc71a0d98b057cba11e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ladislav=20Barto=C5=A1?= Date: Tue, 5 May 2026 21:57:46 +0200 Subject: [PATCH 13/43] Removed useless check --- scripts/qq_scripts/gmx-eta | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/qq_scripts/gmx-eta b/scripts/qq_scripts/gmx-eta index 8d8347f..c2fc46e 100755 --- a/scripts/qq_scripts/gmx-eta +++ b/scripts/qq_scripts/gmx-eta @@ -142,7 +142,7 @@ def main(): # extract ETA from the content try: eta = get_eta_from_content(content) - if eta and datetime.now() <= eta: + if datetime.now() <= eta: print( f"{job_id_name}: Simulation will finish in [bright_blue bold]{format_duration_wdhhmmss(eta - datetime.now())}[/bright_blue bold]." ) From bf3f978a37f801b1834b4074bd344f1124b7442f Mon Sep 17 00:00:00 2001 From: Ladme Date: Wed, 6 May 2026 16:56:28 +0200 Subject: [PATCH 14/43] Respawner prototype --- src/qq_lib/properties/loop.py | 4 +- src/qq_lib/qq.py | 2 + src/qq_lib/respawn/__init__.py | 2 + src/qq_lib/respawn/cli.py | 95 +++++++++++++++++++++++++++++++++ src/qq_lib/respawn/respawner.py | 92 +++++++++++++++++++++++++++++++ tests/test_properties_loop.py | 20 +++---- 6 files changed, 203 insertions(+), 12 deletions(-) create mode 100644 src/qq_lib/respawn/__init__.py create mode 100644 src/qq_lib/respawn/cli.py create mode 100644 src/qq_lib/respawn/respawner.py diff --git a/src/qq_lib/properties/loop.py b/src/qq_lib/properties/loop.py index e2c7671..5485843 100644 --- a/src/qq_lib/properties/loop.py +++ b/src/qq_lib/properties/loop.py @@ -82,7 +82,7 @@ def __init__( self.start = start self.end = end - self.current = current or self._get_cycle() + self.current = current or self.determine_cycle_from_archive() if self.start < 0: raise QQError(f"Attribute 'loop-start' ({self.start}) cannot be negative.") @@ -176,7 +176,7 @@ def to_command_line(self) -> list[str]: ":".join(mode.to_str() for mode in self.archive_mode), ] - def _get_cycle(self) -> int: + def determine_cycle_from_archive(self) -> int: """ Determine the current cycle number based on files in the archive directory. diff --git a/src/qq_lib/qq.py b/src/qq_lib/qq.py index dc79e51..6029a7e 100644 --- a/src/qq_lib/qq.py +++ b/src/qq_lib/qq.py @@ -15,6 +15,7 @@ from qq_lib.killall.cli import killall from qq_lib.nodes.cli import nodes from qq_lib.queues.cli import queues +from qq_lib.respawn.cli import respawn from qq_lib.run.cli import run from qq_lib.shebang.cli import shebang from qq_lib.stat.cli import stat @@ -75,3 +76,4 @@ def cli(ctx: click.Context, version: bool): cli.add_command(nodes) cli.add_command(shebang) cli.add_command(wipe) +cli.add_command(respawn) diff --git a/src/qq_lib/respawn/__init__.py b/src/qq_lib/respawn/__init__.py new file mode 100644 index 0000000..d24db1e --- /dev/null +++ b/src/qq_lib/respawn/__init__.py @@ -0,0 +1,2 @@ +# Released under MIT License. +# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab diff --git a/src/qq_lib/respawn/cli.py b/src/qq_lib/respawn/cli.py new file mode 100644 index 0000000..d440233 --- /dev/null +++ b/src/qq_lib/respawn/cli.py @@ -0,0 +1,95 @@ +# Released under MIT License. +# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab + +import sys +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.common import get_info_files +from qq_lib.core.config import CFG +from qq_lib.core.error import QQError, QQNotSuitableError +from qq_lib.core.error_handlers import ( + handle_general_qq_error, + handle_not_suitable_error, +) +from qq_lib.core.logger import get_logger +from qq_lib.core.repeater import Repeater +from qq_lib.info import Informer +from qq_lib.respawn.respawner import Respawner + +logger = get_logger(__name__) +console = Console() + + +@click.command( + short_help="Respawn a failed/killed job.", + help=f"""Respawn the specified qq job, or all qq jobs in the current directory. + +{click.style("JOB_ID", fg="green")} The identifier of the job to respawn. Optional. + +If JOB_ID is not specified, `{CFG.binary_name} respawn` searches for qq jobs in the current directory. + +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, + help_options_color="bright_blue", +) +@click.argument( + "job", + type=str, + metavar=click.style("JOB_ID", fg="green"), + required=False, + default=None, +) +def respawn(job: str | None) -> NoReturn: + try: + if job: + informers = [Informer.from_job_id(job)] + else: + if not ( + informers := [ + Informer.from_file(info) for info in get_info_files(Path.cwd()) + ] + ): + raise QQError("No qq job info file found.") + + repeater = Repeater(informers, respawn_job) + repeater.on_exception(QQNotSuitableError, handle_not_suitable_error) + repeater.on_exception(QQError, handle_general_qq_error) + repeater.run() + print() + sys.exit(0) + # QQErrors should be caught by Repeater + 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 respawn_job(informer: Informer) -> None: + """ + Attempt to respawn a qq job associated with the specified informer. + + Args: + informer (Informer): Informer associated with the job. + + Raises: + QQNotSuitableError: If the job is not suitable for respawn. + QQError: If the job cannot be respawned. + """ + respawner = Respawner.from_informer(informer) + respawner.print_info(console) + + # make sure that the job can actually be respawned + respawner.ensure_suitable() + + job_id = respawner.respawn() + + logger.info(f"Job '{informer.info.job_id}' successfully respawned as '{job_id}'.") diff --git a/src/qq_lib/respawn/respawner.py b/src/qq_lib/respawn/respawner.py new file mode 100644 index 0000000..45dd273 --- /dev/null +++ b/src/qq_lib/respawn/respawner.py @@ -0,0 +1,92 @@ +# Released under MIT License. +# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab + +from qq_lib.clear import Clearer +from qq_lib.core.error import QQError, QQNotSuitableError +from qq_lib.core.logger import get_logger +from qq_lib.core.operator import Operator +from qq_lib.properties.depend import Depend +from qq_lib.properties.loop import LoopInfo +from qq_lib.properties.states import RealState +from qq_lib.submit import Submitter + +logger = get_logger(__name__) + + +class Respawner(Operator): + def ensure_suitable(self) -> None: + """ + Verify that the job is in a state where it can be respawned. + + Raises: + QQNotSuitableError: If the job is in any other state than failed or killed. + """ + if self._state not in {RealState.FAILED, RealState.KILLED}: + raise QQNotSuitableError( + f"Job cannot be respawned. Job is {str(self._state)}." + ) + + def respawn(self) -> str: + informer = self.get_informer() + input_dir = self._info_file.parent + + dependencies = self._handle_dependencies(informer.info.depend) + if (loop_info := informer.info.loop_info) is not None: + self._ensure_archive_consistent(loop_info) + + submitter = Submitter( + batch_system=informer.batch_system, + queue=informer.info.queue, + account=informer.info.account, + script=input_dir / informer.info.script_name, + job_type=informer.info.job_type, + resources=informer.info.resources, + loop_info=informer.info.loop_info, + exclude=informer.info.excluded_files, + include=informer.info.included_files, + depend=dependencies, + transfer_mode=informer.info.transfer_mode, + server=informer.info.server, + interpreter=informer.info.interpreter, + ) + + # clear files from the input directory + clearer = Clearer(input_dir) + clearer.clear() + + # respawn the job + return submitter.submit() + + def _handle_dependencies(self, dependencies: list[Depend]) -> list[Depend]: + """ + Removes jobs from dependencies that are no longer present in the batch system. + + Without removing these jobs, the respawned job would immediately fail. + """ + BatchSystem = self._informer.batch_system + + filtered = [] + for depend in dependencies: + # get jobs that are still present in the batch system + valid_jobs = [ + job.get_id() + for job_id in depend.jobs + if not (job := BatchSystem.get_batch_job(job_id)).is_empty() + ] + if valid_jobs: + filtered.append(Depend(depend.type, valid_jobs)) + + logger.debug(f"Filtered dependencies: {filtered}.") + return filtered + + def _ensure_archive_consistent(self, loop_info: LoopInfo) -> None: + """ + Ensure that the current loop job cycle matches what we would expect based on the contents of the archive directory. + """ + if ( + archive_cycle := loop_info.determine_cycle_from_archive() + ) != loop_info.current: + raise QQError( + f"Respawning loop job in cycle '{loop_info.current}' but the loop job should continue from cycle '{archive_cycle}' " + "based on the contents of the archive directory. Canceling job respawn." + ) diff --git a/tests/test_properties_loop.py b/tests/test_properties_loop.py index 0f78e37..ee58ca9 100644 --- a/tests/test_properties_loop.py +++ b/tests/test_properties_loop.py @@ -162,13 +162,13 @@ def temp_dir(): def test_get_cycle_returns_start_if_archive_does_not_exist(tmp_path): loop_info = _create_loop_info_stub(5, tmp_path / "nonexistent", "md%04d") - assert loop_info._get_cycle() == 5 + assert loop_info.determine_cycle_from_archive() == 5 def test_get_cycle_returns_start_if_no_matching_files(temp_dir): (temp_dir / "foo.txt").write_text("dummy") loop_info = _create_loop_info_stub(2, temp_dir, "md%04d") - assert loop_info._get_cycle() == 2 + assert loop_info.determine_cycle_from_archive() == 2 def test_get_cycle_selects_highest_number(temp_dir): @@ -176,7 +176,7 @@ def test_get_cycle_selects_highest_number(temp_dir): (temp_dir / "md0002.csv").write_text("x") (temp_dir / "md0007.txt").write_text("x") loop_info = _create_loop_info_stub(0, temp_dir, "md%04d") - assert loop_info._get_cycle() == 7 + assert loop_info.determine_cycle_from_archive() == 7 def test_get_cycle_selects_highest_number_partial_match(temp_dir): @@ -184,7 +184,7 @@ def test_get_cycle_selects_highest_number_partial_match(temp_dir): (temp_dir / "md0002.csv").write_text("x") (temp_dir / "md0007_px.txt").write_text("x") loop_info = _create_loop_info_stub(0, temp_dir, "md%04d") - assert loop_info._get_cycle() == 7 + assert loop_info.determine_cycle_from_archive() == 7 def test_get_cycle_selects_highest_number_partial_match2(temp_dir): @@ -192,7 +192,7 @@ def test_get_cycle_selects_highest_number_partial_match2(temp_dir): (temp_dir / "md0002.csv").write_text("x") (temp_dir / "file_md0007.txt").write_text("x") loop_info = _create_loop_info_stub(0, temp_dir, "md%04d") - assert loop_info._get_cycle() == 7 + assert loop_info.determine_cycle_from_archive() == 7 def test_get_cycle_files_without_digits_are_ignored(temp_dir): @@ -200,7 +200,7 @@ def test_get_cycle_files_without_digits_are_ignored(temp_dir): (temp_dir / "mdxxxx.txt").write_text("x") loop_info = _create_loop_info_stub(3, temp_dir, "md.*") # no numerical values in filenames; use start cycle - assert loop_info._get_cycle() == 3 + assert loop_info.determine_cycle_from_archive() == 3 def test_get_cycle_mixed_files_some_match_some_not(temp_dir): @@ -208,26 +208,26 @@ def test_get_cycle_mixed_files_some_match_some_not(temp_dir): (temp_dir / "md25.xtc").write_text("x") # wrong stem (temp_dir / "md0005.mdp").write_text("x") loop_info = _create_loop_info_stub(0, temp_dir, "md%04d") - assert loop_info._get_cycle() == 5 + assert loop_info.determine_cycle_from_archive() == 5 def test_get_cycle_multiple_digit_sequences_in_stem(temp_dir): (temp_dir / "md0003extra123.tpr").write_text("x") loop_info = _create_loop_info_stub(0, temp_dir, "md.*") - assert loop_info._get_cycle() == 3 + assert loop_info.determine_cycle_from_archive() == 3 def test_get_cycle_start_value_is_used_as_lower_bound(temp_dir): (temp_dir / "md0001.xtc").write_text("x") loop_info = _create_loop_info_stub(5, temp_dir, "md%04d") - assert loop_info._get_cycle() == 5 + assert loop_info.determine_cycle_from_archive() == 5 def test_get_cycle_non_numeric_files_are_ignored_but_numeric_stems_count(temp_dir): (temp_dir / "md0010.xtc").write_text("x") (temp_dir / "mdxxxx.txt").write_text("x") loop_info = _create_loop_info_stub(0, temp_dir, "md.*") - assert loop_info._get_cycle() == 10 + assert loop_info.determine_cycle_from_archive() == 10 def test_to_command_line_basic(): From 5561fcdf0654df9fd4d9dd4389c4972fa3ac27e7 Mon Sep 17 00:00:00 2001 From: Ladme Date: Thu, 7 May 2026 11:01:04 +0200 Subject: [PATCH 15/43] Submitting from remote machine + making submit thread-safe --- CHANGELOG.md | 2 + src/qq_lib/batch/interface/interface.py | 4 + src/qq_lib/batch/pbs/pbs.py | 56 ++++++++++--- src/qq_lib/batch/slurm/slurm.py | 50 +++++++++--- src/qq_lib/batch/slurmlumi/slurm.py | 3 +- src/qq_lib/submit/submitter.py | 104 ++++++++++++------------ tests/test_batch_pbs_pbs.py | 102 ++++++++++++++--------- tests/test_batch_slurm_slurm.py | 19 +++-- 8 files changed, 213 insertions(+), 127 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbbdde5..ba6817a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ - qq now uses Python 3.13 for better generics support. - Fixed type errors in qq scripts. - Refactored BatchMeta. +- Jobs can be now submitted from a remote machine. +- Submitting jobs using `Submitter.submit` is now thread-safe. *** diff --git a/src/qq_lib/batch/interface/interface.py b/src/qq_lib/batch/interface/interface.py index 9972f27..4ebec14 100644 --- a/src/qq_lib/batch/interface/interface.py +++ b/src/qq_lib/batch/interface/interface.py @@ -217,12 +217,15 @@ def job_submit( env_vars: dict[str, str], account: str | None = None, server: str | None = None, + remote_host: str | None = None, ) -> str: """ Submit a job to the batch system. Can also perform additional validation of the job's resources. + This method is NOT guaranteed to be thread-safe. + Args: res (Resources): Resources required for the job. queue (str): Target queue for the job submission. @@ -232,6 +235,7 @@ def job_submit( env_vars (dict[str, str]): Dictionary of environment variables to propagate to the job. account (str | None): Optional account name to use for the job. server (str | None): Optional name of the server to submit the job to. + remote_host (str | None): Optional name of the machine to submit the job from. Returns: str: Unique ID of the submitted job. diff --git a/src/qq_lib/batch/pbs/pbs.py b/src/qq_lib/batch/pbs/pbs.py index 05efa3b..73f0ad4 100644 --- a/src/qq_lib/batch/pbs/pbs.py +++ b/src/qq_lib/batch/pbs/pbs.py @@ -74,11 +74,12 @@ def job_submit( env_vars: dict[str, str], account: str | None = None, server: str | None = None, + remote_host: str | None = None, ) -> str: # account unused _ = account - cls._shared_guard(res, env_vars, server) + cls._shared_guard(res, env_vars, server, remote_host) # set env vars required for Infinity modules # this can be removed once Infinity stops being supported @@ -102,15 +103,35 @@ def job_submit( ) logger.debug(command) - # submit the script - result = subprocess.run( - ["bash"], - input=command, - text=True, - check=False, - capture_output=True, - errors="replace", - ) + if not remote_host: + # submit the script from the current host + result = subprocess.run( + ["bash"], + input=command, + text=True, + check=False, + capture_output=True, + errors="replace", + ) + else: + # submit the script from the remote host + logger.debug( + f"Navigating to '{remote_host}' to execute the submission command '{command}'." + ) + result = subprocess.run( + [ + "ssh", + "-o PasswordAuthentication=no", + "-o GSSAPIAuthentication=yes", + "-o StrictHostKeyChecking=no", # allow unknown hosts + f"-o ConnectTimeout={CFG.timeouts.ssh}", + "-q", # suppress some SSH messages + remote_host, + command, + ], + capture_output=True, + text=True, + ) if result.returncode != 0: raise QQError( @@ -476,7 +497,11 @@ def _get_scratch_dir(cls, job_id: str) -> Path: @classmethod def _shared_guard( - cls, res: Resources, env_vars: dict[str, str], server: str | None + cls, + res: Resources, + env_vars: dict[str, str], + server: str | None, + remote_host: str | None, ) -> None: """ Ensure correct handling of shared vs. local submission directories. @@ -491,6 +516,8 @@ def _shared_guard( If the job is to be submitted to a potentially non-local server but the directory is not shared, a `QQError` is raised. + If the job is to be submitted on a remote host, but the directory is not shared, a `QQError` is raised. + Args: res (Resources): The job's resource configuration. env_vars (dict[str, str]): Dictionary of environment variables to propagate to the job. @@ -514,6 +541,11 @@ def _shared_guard( raise QQError( f"Job was requested to be submitted to server '{server}' which is potentially non-local, but the submission is done from a local filesystem." ) + elif remote_host is not None: + # if we are submitting from a different host + raise QQError( + f"Job was requested to be submitted from host '{remote_host}', but the submission is done from a local filesystem." + ) @classmethod def _translate_submit( @@ -579,7 +611,7 @@ def _translate_submit( command += f"-W depend={converted_depend} " # add script - command += script + command += str(input_dir / script) return command diff --git a/src/qq_lib/batch/slurm/slurm.py b/src/qq_lib/batch/slurm/slurm.py index 64d98cf..7a703b4 100644 --- a/src/qq_lib/batch/slurm/slurm.py +++ b/src/qq_lib/batch/slurm/slurm.py @@ -59,27 +59,48 @@ def job_submit( env_vars: dict[str, str], account: str | None = None, server: str | None = None, + remote_host: str | None = None, ) -> str: # server is unused - _ = server + if server: + logger.warning("The 'server' option is ignored for Slurm.") # intentionally using PBS - PBS._shared_guard(res, env_vars, server) + PBS._shared_guard(res, env_vars, server, remote_host) command = cls._translate_submit( res, queue, script.parent, str(script), job_name, depend, env_vars, account ) - logger.debug(command) - # submit the script - result = subprocess.run( - ["bash"], - input=command, - text=True, - check=False, - capture_output=True, - errors="replace", - ) + if not remote_host: + logger.debug(f"Submitting job using '{command}'.") + result = subprocess.run( + ["bash"], + input=command, + text=True, + check=False, + capture_output=True, + errors="replace", + ) + else: + # submit the script from the remote host + logger.debug( + f"Navigating to '{remote_host}' to execute the submission command '{command}'." + ) + result = subprocess.run( + [ + "ssh", + "-o PasswordAuthentication=no", + "-o GSSAPIAuthentication=yes", + "-o StrictHostKeyChecking=no", # allow unknown hosts + f"-o ConnectTimeout={CFG.timeouts.ssh}", + "-q", # suppress some SSH messages + remote_host, + command, + ], + capture_output=True, + text=True, + ) if result.returncode != 0: raise QQError( @@ -431,8 +452,11 @@ def _translate_submit( if converted_depend := cls._translate_dependencies(depend): command += f"--dependency={converted_depend} " + # set input directory for the job + command += f"--chdir={str(input_dir)} " + # add script - command += script + command += str(input_dir / script) return command diff --git a/src/qq_lib/batch/slurmlumi/slurm.py b/src/qq_lib/batch/slurmlumi/slurm.py index 859d867..dcf34d7 100644 --- a/src/qq_lib/batch/slurmlumi/slurm.py +++ b/src/qq_lib/batch/slurmlumi/slurm.py @@ -46,6 +46,7 @@ def job_submit( env_vars: dict[str, str], account: str | None = None, server: str | None = None, + remote_host: str | None = None, ) -> str: # set the 'lumi_scratch_type' env var to be able to decide in get_scratch_dir # whether to create a scratch directory on /scratch or on /flash @@ -54,7 +55,7 @@ def job_submit( env_vars[CFG.env_vars.lumi_scratch_type] = res.work_dir return super().job_submit( - res, queue, script, job_name, depend, env_vars, account, server + res, queue, script, job_name, depend, env_vars, account, server, remote_host ) @classmethod diff --git a/src/qq_lib/submit/submitter.py b/src/qq_lib/submit/submitter.py index c64cc96..286191f 100644 --- a/src/qq_lib/submit/submitter.py +++ b/src/qq_lib/submit/submitter.py @@ -4,7 +4,6 @@ import getpass import os import socket -from contextlib import chdir from datetime import datetime from pathlib import Path @@ -124,15 +123,18 @@ def __init__( f"Script '{self._script}' has an invalid shebang. The first line of the script should be '#!/usr/bin/env -S {CFG.binary_name} run'." ) - def submit(self) -> str: + def submit(self, remote: str | None = None) -> str: """ Submit the script to the batch system. Sets required environment variables, calls the batch system's job submission mechanism, and creates an info file with job metadata. - Note that this method temporarily changes the current working directory, - and is therefore not thread-safe. + This method is thread-safe, if the submission is done from the current machine. + + Args: + remote (str | None): Name of the machine from which the job should be submitted. + If `None`, the current machine is used. Returns: str: The job ID of the submitted job. @@ -140,58 +142,52 @@ def submit(self) -> str: Raises: QQError: If job submission fails. """ - # move to the script's parent directory and submit the script - # with PBS it is possible to submit the script from anywhere - # but with Slurm the input directory path is then not set correctly - # it is safer and easier to just move to the input directory, - # execute the command and then return back - with chdir(self._input_dir): - # submit the job - job_id = self._batch_system.job_submit( - self._resources, - self._queue, - self._script, - self._job_name, - self._depend, - self._create_env_vars_dict(), - self._account, - self._server, - ) + job_id = self._batch_system.job_submit( + self._resources, + self._queue, + self._script, + self._job_name, + self._depend, + self._create_env_vars_dict(), + self._account, + self._server, + ) - # 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(), - 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, - ) + # 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=remote or socket.getfqdn(), + 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, ) - informer.to_file(self._info_file) - return job_id + ) + + # 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) + return job_id def continues_loop(self) -> bool: """ diff --git a/tests/test_batch_pbs_pbs.py b/tests/test_batch_pbs_pbs.py index ca3f26b..e7c6b37 100644 --- a/tests/test_batch_pbs_pbs.py +++ b/tests/test_batch_pbs_pbs.py @@ -66,7 +66,7 @@ def test_shared_guard_sets_env_var(): # patch is_shared to return True with patch.object(PBS, "is_shared", return_value=True): - PBS._shared_guard(Resources(work_dir="scratch_local"), env_vars, None) + PBS._shared_guard(Resources(work_dir="scratch_local"), env_vars, None, None) assert env_vars[CFG.env_vars.shared_submit] == "true" # previous env vars not removed assert env_vars[CFG.env_vars.guard] == "true" @@ -77,7 +77,7 @@ def test_shared_guard_does_not_set_env_var(): # patch is_shared to return False with patch.object(PBS, "is_shared", return_value=False): - PBS._shared_guard(Resources(work_dir="scratch_local"), env_vars, None) + PBS._shared_guard(Resources(work_dir="scratch_local"), env_vars, None, None) assert CFG.env_vars.shared_submit not in env_vars # previous env vars not removed assert env_vars[CFG.env_vars.guard] == "true" @@ -89,7 +89,7 @@ def test_shared_guard_input_dir_does_not_raise(dir): # patch isShared to return True with patch.object(PBS, "is_shared", return_value=True): - PBS._shared_guard(Resources(work_dir=dir), env_vars, None) + PBS._shared_guard(Resources(work_dir=dir), env_vars, None, None) assert env_vars[CFG.env_vars.shared_submit] == "true" @@ -105,7 +105,7 @@ def test_shared_guard_input_dir_raises(dir): match="Job was requested to run directly in the submission directory", ), ): - PBS._shared_guard(Resources(work_dir=dir), env_vars, None) + PBS._shared_guard(Resources(work_dir=dir), env_vars, None, None) assert CFG.env_vars.shared_submit not in env_vars @@ -117,13 +117,35 @@ def test_shared_guard_raises_when_server_specified_and_not_shared(): match="which is potentially non-local", ), ): - PBS._shared_guard(Resources(work_dir="scratch_local"), {}, "server") + PBS._shared_guard(Resources(work_dir="scratch_local"), {}, "server", None) def test_shared_guard_does_not_raise_when_server_specified_and_shared(): env_vars = {} with patch.object(PBS, "is_shared", return_value=True): - PBS._shared_guard(Resources(work_dir="scratch_local"), env_vars, "sokar") + PBS._shared_guard(Resources(work_dir="scratch_local"), env_vars, "sokar", None) + assert env_vars[CFG.env_vars.shared_submit] == "true" + + +def test_shared_guard_raises_when_remote_specified_and_not_shared(): + with ( + patch.object(PBS, "is_shared", return_value=False), + pytest.raises( + QQError, + match="requested to be submitted from host", + ), + ): + PBS._shared_guard( + Resources(work_dir="scratch_local"), {}, None, "random.node.org" + ) + + +def test_shared_guard_does_not_raise_when_remote_specified_and_shared(): + env_vars = {} + with patch.object(PBS, "is_shared", return_value=True): + PBS._shared_guard( + Resources(work_dir="scratch_local"), env_vars, None, "random.node.org" + ) assert env_vars[CFG.env_vars.shared_submit] == "true" @@ -587,7 +609,7 @@ def test_translate_submit_minimal_fields(): res = Resources(nnodes=1, ncpus=1, mem="1gb", work_dir="input_dir") assert ( PBS._translate_submit(res, "gpu", None, Path("tmp"), "script.sh", "job", [], {}) - == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=1,mpiprocs=1,mem=1048576kb script.sh" + == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=1,mpiprocs=1,mem=1048576kb tmp/script.sh" ) @@ -604,7 +626,7 @@ def test_translate_submit_with_server(): [], {}, ) - == f"qsub -N job -q gpu@server.random.address.com -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=1,mpiprocs=1,mem=1048576kb script.sh" + == f"qsub -N job -q gpu@server.random.address.com -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=1,mpiprocs=1,mem=1048576kb tmp/script.sh" ) @@ -621,7 +643,7 @@ def test_translate_submit_with_known_server(): [], {}, ) - == f"qsub -N job -q gpu@sokar-pbs.ncbr.muni.cz -j eo -e sokar.ncbr.muni.cz:tmp/job{CFG.suffixes.qq_out} -l ncpus=1,mpiprocs=1,mem=1048576kb script.sh" + == f"qsub -N job -q gpu@sokar-pbs.ncbr.muni.cz -j eo -e sokar.ncbr.muni.cz:tmp/job{CFG.suffixes.qq_out} -l ncpus=1,mpiprocs=1,mem=1048576kb tmp/script.sh" ) @@ -631,7 +653,7 @@ def test_translate_submit_ncpus_ngpus_per_node(): ) assert ( PBS._translate_submit(res, "gpu", None, Path("tmp"), "script.sh", "job", [], {}) - == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=1,mpiprocs=1,mem=1048576kb,ngpus=1 script.sh" + == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=1,mpiprocs=1,mem=1048576kb,ngpus=1 tmp/script.sh" ) @@ -648,7 +670,7 @@ def test_translate_submit_with_env_vars(): [], {CFG.env_vars.guard: "true", CFG.env_vars.batch_system: "PBS"}, ) - == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -v \"{CFG.env_vars.guard}='true'\",\"{CFG.env_vars.batch_system}='PBS'\" -l ncpus=1,mpiprocs=1,mem=1048576kb script.sh" + == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -v \"{CFG.env_vars.guard}='true'\",\"{CFG.env_vars.batch_system}='PBS'\" -l ncpus=1,mpiprocs=1,mem=1048576kb tmp/script.sh" ) @@ -656,7 +678,7 @@ def test_translate_submit_multiple_nodes(): res = Resources(nnodes=4, ncpus=8, mem="1gb", work_dir="input_dir") assert ( PBS._translate_submit(res, "gpu", None, Path("tmp"), "script.sh", "job", [], {}) - == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l select=4:ncpus=2:mpiprocs=2:mem=262144kb -l place=vscatter script.sh" + == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l select=4:ncpus=2:mpiprocs=2:mem=262144kb -l place=vscatter tmp/script.sh" ) @@ -666,7 +688,7 @@ def test_translate_submit_multiple_nodes_ncpus_and_ngpus_per_node(): ) assert ( PBS._translate_submit(res, "gpu", None, Path("tmp"), "script.sh", "job", [], {}) - == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l select=4:ncpus=8:mpiprocs=8:mem=262144kb:ngpus=1 -l place=vscatter script.sh" + == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l select=4:ncpus=8:mpiprocs=8:mem=262144kb:ngpus=1 -l place=vscatter tmp/script.sh" ) @@ -683,7 +705,7 @@ def test_translate_submit_multiple_nodes_with_env_vars(): [], {CFG.env_vars.guard: "true", CFG.env_vars.batch_system: "PBS"}, ) - == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -v \"{CFG.env_vars.guard}='true'\",\"{CFG.env_vars.batch_system}='PBS'\" -l select=4:ncpus=2:mpiprocs=2:mem=262144kb -l place=vscatter script.sh" + == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -v \"{CFG.env_vars.guard}='true'\",\"{CFG.env_vars.batch_system}='PBS'\" -l select=4:ncpus=2:mpiprocs=2:mem=262144kb -l place=vscatter tmp/script.sh" ) @@ -695,7 +717,7 @@ def test_translate_submit_with_walltime(): PBS._translate_submit( res, "queue", None, Path("tmp"), "script.sh", "job", [], {} ) - == f"qsub -N job -q queue -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=2,mpiprocs=2,mem=2097152kb -l walltime=24:26:01 script.sh" + == f"qsub -N job -q queue -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=2,mpiprocs=2,mem=2097152kb -l walltime=24:26:01 tmp/script.sh" ) @@ -707,7 +729,7 @@ def test_translate_submit_with_walltime2(): PBS._translate_submit( res, "queue", None, Path("tmp"), "script.sh", "job", [], {} ) - == f"qsub -N job -q queue -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=2,mpiprocs=2,mem=2097152kb -l walltime=12:30:15 script.sh" + == f"qsub -N job -q queue -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=2,mpiprocs=2,mem=2097152kb -l walltime=12:30:15 tmp/script.sh" ) @@ -726,7 +748,7 @@ def test_translate_submit_with_walltime_and_env_vars(): [], {CFG.env_vars.guard: "true", CFG.env_vars.batch_system: "PBS"}, ) - == f"qsub -N job -q queue -j eo -e tmp/job{CFG.suffixes.qq_out} -v \"{CFG.env_vars.guard}='true'\",\"{CFG.env_vars.batch_system}='PBS'\" -l ncpus=2,mpiprocs=2,mem=2097152kb -l walltime=24:26:01 script.sh" + == f"qsub -N job -q queue -j eo -e tmp/job{CFG.suffixes.qq_out} -v \"{CFG.env_vars.guard}='true'\",\"{CFG.env_vars.batch_system}='PBS'\" -l ncpus=2,mpiprocs=2,mem=2097152kb -l walltime=24:26:01 tmp/script.sh" ) @@ -734,7 +756,7 @@ def test_translate_submit_work_dir_scratch_shm(): res = Resources(nnodes=1, ncpus=1, mem="8gb", work_dir="scratch_shm") assert ( PBS._translate_submit(res, "gpu", None, Path("tmp"), "script.sh", "job", [], {}) - == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=1,mpiprocs=1,mem=8388608kb,scratch_shm=true script.sh" + == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=1,mpiprocs=1,mem=8388608kb,scratch_shm=true tmp/script.sh" ) @@ -744,7 +766,7 @@ def test_translate_submit_scratch_local_work_size(): ) assert ( PBS._translate_submit(res, "gpu", None, Path("tmp"), "script.sh", "job", [], {}) - == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l select=2:ncpus=1:mpiprocs=1:mem=2097152kb:scratch_local=8388608kb -l place=vscatter script.sh" + == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l select=2:ncpus=1:mpiprocs=1:mem=2097152kb:scratch_local=8388608kb -l place=vscatter tmp/script.sh" ) @@ -758,7 +780,7 @@ def test_translate_submit_scratch_local_work_size_per_node(): ) assert ( PBS._translate_submit(res, "gpu", None, Path("tmp"), "script.sh", "job", [], {}) - == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l select=2:ncpus=1:mpiprocs=1:mem=2097152kb:scratch_local=16777216kb -l place=vscatter script.sh" + == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l select=2:ncpus=1:mpiprocs=1:mem=2097152kb:scratch_local=16777216kb -l place=vscatter tmp/script.sh" ) @@ -768,7 +790,7 @@ def test_translate_submit_scratch_ssd_work_size(): ) assert ( PBS._translate_submit(res, "gpu", None, Path("tmp"), "script.sh", "job", [], {}) - == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l select=2:ncpus=1:mpiprocs=1:mem=2097152kb:scratch_ssd=8388608kb -l place=vscatter script.sh" + == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l select=2:ncpus=1:mpiprocs=1:mem=2097152kb:scratch_ssd=8388608kb -l place=vscatter tmp/script.sh" ) @@ -778,7 +800,7 @@ def test_translate_submit_scratch_shared_work_size(): ) assert ( PBS._translate_submit(res, "gpu", None, Path("tmp"), "script.sh", "job", [], {}) - == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l select=2:ncpus=1:mpiprocs=1:mem=2097152kb:scratch_shared=8388608kb -l place=vscatter script.sh" + == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l select=2:ncpus=1:mpiprocs=1:mem=2097152kb:scratch_shared=8388608kb -l place=vscatter tmp/script.sh" ) @@ -788,7 +810,7 @@ def test_translate_submit_work_size_per_cpu(): ) assert ( PBS._translate_submit(res, "gpu", None, Path("tmp"), "script.sh", "job", [], {}) - == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=8,mpiprocs=8,mem=4194304kb,scratch_local=16777216kb script.sh" + == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=8,mpiprocs=8,mem=4194304kb,scratch_local=16777216kb tmp/script.sh" ) @@ -802,7 +824,7 @@ def test_translate_submit_work_size_per_cpu_with_cpus_per_node(): ) assert ( PBS._translate_submit(res, "gpu", None, Path("tmp"), "script.sh", "job", [], {}) - == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=8,mpiprocs=8,mem=4194304kb,scratch_local=16777216kb script.sh" + == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=8,mpiprocs=8,mem=4194304kb,scratch_local=16777216kb tmp/script.sh" ) @@ -812,7 +834,7 @@ def test_translate_submit_work_size_per_cpu_multiple_nodes(): ) assert ( PBS._translate_submit(res, "gpu", None, Path("tmp"), "script.sh", "job", [], {}) - == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l select=3:ncpus=1:mpiprocs=1:mem=1398102kb:scratch_local=2097152kb -l place=vscatter script.sh" + == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l select=3:ncpus=1:mpiprocs=1:mem=1398102kb:scratch_local=2097152kb -l place=vscatter tmp/script.sh" ) @@ -822,7 +844,7 @@ def test_translate_submit_mem_per_cpu(): ) assert ( PBS._translate_submit(res, "gpu", None, Path("tmp"), "script.sh", "job", [], {}) - == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=4,mpiprocs=4,mem=8388608kb,scratch_local=10485760kb script.sh" + == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=4,mpiprocs=4,mem=8388608kb,scratch_local=10485760kb tmp/script.sh" ) @@ -836,7 +858,7 @@ def test_translate_submit_mem_per_cpu_with_ncpus_per_node(): ) assert ( PBS._translate_submit(res, "gpu", None, Path("tmp"), "script.sh", "job", [], {}) - == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=4,mpiprocs=4,mem=8388608kb,scratch_local=10485760kb script.sh" + == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=4,mpiprocs=4,mem=8388608kb,scratch_local=10485760kb tmp/script.sh" ) @@ -850,7 +872,7 @@ def test_translate_submit_mem_per_node(): ) assert ( PBS._translate_submit(res, "gpu", None, Path("tmp"), "script.sh", "job", [], {}) - == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=4,mpiprocs=4,mem=8388608kb,scratch_local=10485760kb script.sh" + == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=4,mpiprocs=4,mem=8388608kb,scratch_local=10485760kb tmp/script.sh" ) @@ -860,7 +882,7 @@ def test_translate_submit_mem_per_cpu_multiple_nodes(): ) assert ( PBS._translate_submit(res, "gpu", None, Path("tmp"), "script.sh", "job", [], {}) - == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l select=2:ncpus=2:mpiprocs=2:mem=4194304kb:scratch_local=10485760kb -l place=vscatter script.sh" + == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l select=2:ncpus=2:mpiprocs=2:mem=4194304kb:scratch_local=10485760kb -l place=vscatter tmp/script.sh" ) @@ -874,7 +896,7 @@ def test_translate_submit_mem_per_node_multiple_nodes(): ) assert ( PBS._translate_submit(res, "gpu", None, Path("tmp"), "script.sh", "job", [], {}) - == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l select=2:ncpus=2:mpiprocs=2:mem=4194304kb:scratch_local=10485760kb -l place=vscatter script.sh" + == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l select=2:ncpus=2:mpiprocs=2:mem=4194304kb:scratch_local=10485760kb -l place=vscatter tmp/script.sh" ) @@ -888,7 +910,7 @@ def test_translate_submit_mem_per_cpu_and_work_size_per_cpu(): ) assert ( PBS._translate_submit(res, "gpu", None, Path("tmp"), "script.sh", "job", [], {}) - == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=4,mpiprocs=4,mem=8388608kb,scratch_local=20971520kb script.sh" + == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=4,mpiprocs=4,mem=8388608kb,scratch_local=20971520kb tmp/script.sh" ) @@ -902,7 +924,7 @@ def test_translate_submit_mem_per_cpu_and_work_size_per_cpu_multiple_nodes(): ) assert ( PBS._translate_submit(res, "gpu", None, Path("tmp"), "script.sh", "job", [], {}) - == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l select=2:ncpus=2:mpiprocs=2:mem=4194304kb:scratch_local=10485760kb -l place=vscatter script.sh" + == f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} -l select=2:ncpus=2:mpiprocs=2:mem=4194304kb:scratch_local=10485760kb -l place=vscatter tmp/script.sh" ) @@ -918,7 +940,7 @@ def test_translate_submit_with_props(): PBS._translate_submit( res, "queue", None, Path("tmp"), "script.sh", "job", [], {} ) - == f"qsub -N job -q queue -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=1,mpiprocs=1,mem=1048576kb,vnode=my_node,infiniband=true script.sh" + == f"qsub -N job -q queue -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=1,mpiprocs=1,mem=1048576kb,vnode=my_node,infiniband=true tmp/script.sh" ) @@ -941,7 +963,7 @@ def test_translate_submit_with_props_and_env_vars(): [], {CFG.env_vars.guard: "true", CFG.env_vars.batch_system: "PBS"}, ) - == f"qsub -N job -q queue -j eo -e tmp/job{CFG.suffixes.qq_out} -v \"{CFG.env_vars.guard}='true'\",\"{CFG.env_vars.batch_system}='PBS'\" -l ncpus=1,mpiprocs=1,mem=1048576kb,vnode=my_node,infiniband=true script.sh" + == f"qsub -N job -q queue -j eo -e tmp/job{CFG.suffixes.qq_out} -v \"{CFG.env_vars.guard}='true'\",\"{CFG.env_vars.batch_system}='PBS'\" -l ncpus=1,mpiprocs=1,mem=1048576kb,vnode=my_node,infiniband=true tmp/script.sh" ) @@ -973,7 +995,7 @@ def test_translate_submit_complex_case(): f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} " f"-v \"{CFG.env_vars.info_file}='/path/to/job/job.qqinfo'\",\"{CFG.env_vars.input_dir}='/path/to/job/'\",\"{CFG.env_vars.guard}='true'\" " f"-l select=3:ncpus=2:mpiprocs=2:mem=1747627kb:ngpus=1:scratch_local=4194304kb:cl_cluster=true " - f"-l walltime=1:30:00 -l place=vscatter myscript.sh" + f"-l walltime=1:30:00 -l place=vscatter tmp/myscript.sh" ) @@ -1005,7 +1027,7 @@ def test_translate_submit_complex_case_with_server(): f"qsub -N job -q gpu@server.fake.address.com -j eo -e tmp/job{CFG.suffixes.qq_out} " f"-v \"{CFG.env_vars.info_file}='/path/to/job/job.qqinfo'\",\"{CFG.env_vars.input_dir}='/path/to/job/'\",\"{CFG.env_vars.guard}='true'\" " f"-l select=3:ncpus=2:mpiprocs=2:mem=1747627kb:ngpus=1:scratch_local=4194304kb:cl_cluster=true " - f"-l walltime=1:30:00 -l place=vscatter myscript.sh" + f"-l walltime=1:30:00 -l place=vscatter tmp/myscript.sh" ) @@ -1015,7 +1037,7 @@ def test_translate_submit_single_depend(): cmd = PBS._translate_submit( res, "queue", None, Path("tmp"), "script.sh", "job", depend, {} ) - expected = f"qsub -N job -q queue -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=1,mpiprocs=1,mem=1048576kb -W depend=after:123 script.sh" + expected = f"qsub -N job -q queue -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=1,mpiprocs=1,mem=1048576kb -W depend=after:123 tmp/script.sh" assert cmd == expected @@ -1025,7 +1047,7 @@ def test_translate_submit_multiple_jobs_depend(): cmd = PBS._translate_submit( res, "queue", None, Path("tmp"), "script.sh", "job", depend, {} ) - expected = f"qsub -N job -q queue -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=1,mpiprocs=1,mem=1048576kb -W depend=afterok:1:2 script.sh" + expected = f"qsub -N job -q queue -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=1,mpiprocs=1,mem=1048576kb -W depend=afterok:1:2 tmp/script.sh" assert cmd == expected @@ -1038,7 +1060,7 @@ def test_translate_submit_multiple_dependencies(): cmd = PBS._translate_submit( res, "queue", None, Path("tmp"), "script.sh", "job", depend, {} ) - expected = f"qsub -N job -q queue -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=1,mpiprocs=1,mem=1048576kb -W depend=afterok:1,afternotok:2 script.sh" + expected = f"qsub -N job -q queue -j eo -e tmp/job{CFG.suffixes.qq_out} -l ncpus=1,mpiprocs=1,mem=1048576kb -W depend=afterok:1,afternotok:2 tmp/script.sh" assert cmd == expected @@ -1072,7 +1094,7 @@ def test_translate_submit_complex_with_depend(): f"qsub -N job -q gpu -j eo -e tmp/job{CFG.suffixes.qq_out} " f"-v \"{CFG.env_vars.info_file}='/path/to/job/job.qqinfo'\",\"{CFG.env_vars.input_dir}='/path/to/job/'\",\"{CFG.env_vars.guard}='true'\" " f"-l select=2:ncpus=2:mpiprocs=2:mem=2097152kb:scratch_local=4194304kb:cl_cluster=true " - "-l walltime=01:00:00 -l place=vscatter -W depend=afterany:42:43 myscript.sh" + "-l walltime=01:00:00 -l place=vscatter -W depend=afterany:42:43 tmp/myscript.sh" ) assert cmd == expected diff --git a/tests/test_batch_slurm_slurm.py b/tests/test_batch_slurm_slurm.py index ec4c5ac..7279308 100644 --- a/tests/test_batch_slurm_slurm.py +++ b/tests/test_batch_slurm_slurm.py @@ -388,7 +388,8 @@ def test_slurm_translate_submit_basic_command(): assert f"--mem={(res.mem // res.nnodes).to_str_exact_slurm()}" in command assert f"--gpus-per-node={res.ngpus // res.nnodes}" in command assert f"--time={res.walltime}" in command - assert command.endswith(script) + assert f"--chdir={input_dir}" in command + assert command.endswith(str(input_dir / script)) def test_slurm_translate_submit_basic_command_with_per_node_properties(): @@ -423,7 +424,8 @@ def test_slurm_translate_submit_basic_command_with_per_node_properties(): assert f"--mem={res.mem_per_node.to_str_exact_slurm()}" in command assert f"--gpus-per-node={res.ngpus_per_node}" in command assert f"--time={res.walltime}" in command - assert command.endswith(script) + assert f"--chdir={input_dir}" in command + assert command.endswith(str(input_dir / script)) def test_slurm_translate_submit_with_account_and_env_vars(): @@ -448,7 +450,8 @@ def test_slurm_translate_submit_with_account_and_env_vars(): assert "--account project123" in command assert '--export ALL,VAR1="A",VAR2="B"' in command - assert command.endswith(script) + assert f"--chdir={input_dir}" in command + assert command.endswith(str(input_dir / script)) def test_slurm_translate_submit_with_dependencies(): @@ -470,7 +473,8 @@ def test_slurm_translate_submit_with_dependencies(): ) assert "--dependency=afterok:111:222" in command - assert command.endswith(script) + assert f"--chdir={input_dir}" in command + assert command.endswith(str(input_dir / script)) def test_slurm_translate_submit_with_props_true_only(): @@ -492,7 +496,8 @@ def test_slurm_translate_submit_with_props_true_only(): ) assert '--constraint="gpu&ssd"' in command - assert command.endswith(script) + assert f"--chdir={input_dir}" in command + assert command.endswith(str(input_dir / script)) def test_slurm_translate_submit_raises_on_invalid_prop_value(): @@ -746,7 +751,7 @@ def test_slurm_job_submit_success(mock_guard, mock_translate, mock_run): result = Slurm.job_submit(res, "qgpu", script, "job1", [], {}, "acc") - mock_guard.assert_called_once_with(res, {}, None) + mock_guard.assert_called_once_with(res, {}, None, None) mock_translate.assert_called_once() mock_run.assert_called_once() assert result == "56789" @@ -763,7 +768,7 @@ def test_slurm_job_submit_raises_on_error(mock_guard, mock_translate, mock_run): with pytest.raises(QQError, match="Failed to submit script"): Slurm.job_submit(res, "qgpu", script, "fail_job", [], {}, None) - mock_guard.assert_called_once_with(res, {}, None) + mock_guard.assert_called_once_with(res, {}, None, None) mock_translate.assert_called_once() mock_run.assert_called_once() From 88047613e2f4ed2ebaa0aa06567d3e415d6552e6 Mon Sep 17 00:00:00 2001 From: Ladme Date: Thu, 7 May 2026 11:14:22 +0200 Subject: [PATCH 16/43] Switched env var priority when resolving input dir --- src/qq_lib/batch/pbs/job.py | 8 ++++++-- src/qq_lib/batch/slurm/job.py | 1 - 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/qq_lib/batch/pbs/job.py b/src/qq_lib/batch/pbs/job.py index 1a0ea84..09408b1 100644 --- a/src/qq_lib/batch/pbs/job.py +++ b/src/qq_lib/batch/pbs/job.py @@ -267,8 +267,12 @@ def get_input_dir(self) -> Path | None: return None if not ( - input_dir := env_vars.get("PBS_O_WORKDIR") # try PBS first - or env_vars.get(CFG.env_vars.input_dir) # if this fails, try qq + # try qq first + # we always try qq first because it provides the most reliable information + # PBS sometimes sets the input directory to absolute path (resolving symlinks) + # which is not necessarily what we want + input_dir := env_vars.get(CFG.env_vars.input_dir) + or env_vars.get("PBS_O_WORKDIR") # if this fails, try PBS or env_vars.get("INF_INPUT_DIR") # if this fails, try Infinity ): logger.debug(f"Could not obtain input directory for '{self._job_id}'.") diff --git a/src/qq_lib/batch/slurm/job.py b/src/qq_lib/batch/slurm/job.py index 1c50822..5b6b12d 100644 --- a/src/qq_lib/batch/slurm/job.py +++ b/src/qq_lib/batch/slurm/job.py @@ -281,7 +281,6 @@ def get_input_machine(self) -> str | None: return None def get_input_dir(self) -> Path | None: - # note that Slurm's WorkDir corresponds to the directory from which sbatch was run if not (raw_dir := self._info.get("WorkDir")): logger.debug(f"Could not obtain input directory for '{self._job_id}'.") return None From bd6871596827a23e3b600e7d9ccd73e93a77c240 Mon Sep 17 00:00:00 2001 From: Ladme Date: Thu, 7 May 2026 14:24:39 +0200 Subject: [PATCH 17/43] Refactored resubmitting --- src/qq_lib/batch/interface/interface.py | 48 +---- src/qq_lib/batch/slurmit4i/slurm.py | 32 +-- src/qq_lib/core/config.py | 14 ++ src/qq_lib/properties/info.py | 4 + src/qq_lib/properties/resubmit_host.py | 121 +++++++++++ src/qq_lib/run/resubmitter.py | 158 ++++++++++++++ src/qq_lib/run/runner.py | 17 +- tests/test_batch_slurm_slurm.py | 14 -- tests/test_batch_slurmit4i_slurm.py | 37 ---- tests/test_properties_resubmit_host.py | 129 ++++++++++++ tests/test_run_resubmitter.py | 263 ++++++++++++++++++++++++ tests/test_run_runner.py | 27 +-- 12 files changed, 718 insertions(+), 146 deletions(-) create mode 100644 src/qq_lib/properties/resubmit_host.py create mode 100644 src/qq_lib/run/resubmitter.py create mode 100644 tests/test_properties_resubmit_host.py create mode 100644 tests/test_run_resubmitter.py diff --git a/src/qq_lib/batch/interface/interface.py b/src/qq_lib/batch/interface/interface.py index 4ebec14..a0b81d5 100644 --- a/src/qq_lib/batch/interface/interface.py +++ b/src/qq_lib/batch/interface/interface.py @@ -16,6 +16,7 @@ from qq_lib.core.error import QQError from qq_lib.core.logger import get_logger from qq_lib.core.logical_paths import logical_resolve +from qq_lib.properties.resubmit_host import InputHost, ResubmitHost from .job import BatchJobInterface from .node import BatchNodeInterface @@ -851,51 +852,16 @@ def is_shared(cls, directory: Path) -> bool: return result.returncode != 0 @classmethod - def resubmit( - cls, input_machine: str, input_dir: Path, command_line: list[str] - ) -> None: + def get_default_resubmit_hosts(cls) -> list[ResubmitHost]: """ - Resubmit a job to the batch system. - - The default implementation connects via SSH to the specified machine, - changes into the job directory, and re-executes the original job - submission command (`qq submit ...`). - - If the resubmission fails, a QQError is raised. + Get the default job resubmission hosts for this batch system. - Args: - input_machine (str): Name of the host from which the job is to be submitted. - input_dir (Path): Path to the job's input directory. - command_line (list[str]): Options and arguments to use for submitting. + In the default implementation, resubmission from the input machine is attempted. - Raises: - QQError: If the resubmission fails (non-zero return code from the - SSH command). + Returns: + list[ResubmitHost]: A list of resubmission hosts. """ - qq_submit_command = f"{CFG.binary_name} submit {' '.join(command_line)}" - - logger.debug( - f"Navigating to '{input_machine}:{str(input_dir)}' to execute '{qq_submit_command}'." - ) - result = subprocess.run( - [ - "ssh", - "-o PasswordAuthentication=no", - "-o GSSAPIAuthentication=yes", - "-o StrictHostKeyChecking=no", # allow unknown hosts - f"-o ConnectTimeout={CFG.timeouts.ssh}", - "-q", # suppress some SSH messages - input_machine, - f"cd {str(input_dir)} && {qq_submit_command}", - ], - capture_output=True, - text=True, - ) - - if result.returncode != 0: - raise QQError( - f"Could not resubmit the job on '{input_machine}': {result.stderr.strip()}." - ) + return [InputHost()] @classmethod def sort_jobs(cls, jobs: list[TBatchJob]) -> None: diff --git a/src/qq_lib/batch/slurmit4i/slurm.py b/src/qq_lib/batch/slurmit4i/slurm.py index c015910..b91dcd2 100644 --- a/src/qq_lib/batch/slurmit4i/slurm.py +++ b/src/qq_lib/batch/slurmit4i/slurm.py @@ -4,7 +4,6 @@ import getpass import os import shutil -import subprocess from pathlib import Path from qq_lib.batch.interface import BatchInterface @@ -15,6 +14,7 @@ from qq_lib.core.error import QQError from qq_lib.core.logger import get_logger from qq_lib.properties.resources import Resources +from qq_lib.properties.resubmit_host import ResubmitHost, WorkHost logger = get_logger(__name__) @@ -212,34 +212,8 @@ def is_shared(cls, directory: Path) -> bool: return True @classmethod - def resubmit( - cls, input_machine: str, input_dir: Path, command_line: list[str] - ) -> None: - # input machine is unused, resubmit from the current machine - _ = input_machine - - qq_submit_command = f"{CFG.binary_name} submit {' '.join(command_line)}" - - logger.debug(f"Navigating to '{input_dir}' to execute '{qq_submit_command}'.") - try: - os.chdir(input_dir) - except Exception as e: - raise QQError( - f"Could not resubmit the job. Could not navigate to '{input_dir}': {e}." - ) from e - - logger.debug(f"Navigated to {str(input_dir)}.") - result = subprocess.run( - ["bash"], - input=qq_submit_command, - text=True, - check=False, - capture_output=True, - errors="replace", - ) - - if result.returncode != 0: - raise QQError(f"Could not resubmit the job: {result.stderr.strip()}.") + def get_default_resubmit_hosts(cls) -> list[ResubmitHost]: + return [WorkHost()] @classmethod def _get_default_resources(cls) -> Resources: diff --git a/src/qq_lib/core/config.py b/src/qq_lib/core/config.py index 951a826..5732457 100644 --- a/src/qq_lib/core/config.py +++ b/src/qq_lib/core/config.py @@ -108,6 +108,19 @@ class RunnerSettings: default_interpreter: str = "bash" +@dataclass +class ResubmitterSettings: + """Settings for Resubmitter operations.""" + + # Maximum number of attempts when retrying an operation. + retry_tries: int = 3 + # Wait time (in seconds) between retry attempts. + retry_wait: int = 300 + # List of hosts from which job resubmission should be attempted. + # If empty, the batch system defaults are used. + default_resubmit_hosts: str = "" + + @dataclass class ArchiverSettings: """Settings for Archiver operations.""" @@ -454,6 +467,7 @@ class Config: env_vars: EnvironmentVariables = field(default_factory=EnvironmentVariables) timeouts: TimeoutSettings = field(default_factory=TimeoutSettings) runner: RunnerSettings = field(default_factory=RunnerSettings) + resubmitter: ResubmitterSettings = field(default_factory=ResubmitterSettings) archiver: ArchiverSettings = field(default_factory=ArchiverSettings) goer: GoerSettings = field(default_factory=GoerSettings) presenter: PresenterSettings = field(default_factory=PresenterSettings) diff --git a/src/qq_lib/properties/info.py b/src/qq_lib/properties/info.py index 0c77c5c..c8a638a 100644 --- a/src/qq_lib/properties/info.py +++ b/src/qq_lib/properties/info.py @@ -28,6 +28,7 @@ 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.resubmit_host import ResubmitHost from qq_lib.properties.transfer_mode import Success, TransferMode from .job_type import JobType @@ -119,6 +120,9 @@ class Info: # to the default (main) batch server the input machine is connected to server: str | None = None + # Hosts from which a loop job or a continuous job should be resubmitted + resubmit_from: list[ResubmitHost] | None = None + # Interpreter to use for running the submitted script interpreter: str | None = None diff --git a/src/qq_lib/properties/resubmit_host.py b/src/qq_lib/properties/resubmit_host.py new file mode 100644 index 0000000..71a6243 --- /dev/null +++ b/src/qq_lib/properties/resubmit_host.py @@ -0,0 +1,121 @@ +# Released under MIT License. +# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab + + +import re +from abc import ABC, abstractmethod +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ResubmitHost(ABC): + """ + A host target for resubmitting a loop or continuous job. + + This abstract base class represents a destination host where a batch job + can be resubmitted. Concrete implementations resolve to actual hostnames + via the `convert` method. + + Subclasses: + InputHost: Resolves to the original input machine. + WorkHost: Resolves to the current working node. + CustomHost: Resolves to an explicitly specified hostname. + """ + + @classmethod + def from_str(cls, s: str) -> "ResubmitHost": + """ + Parses a single resubmission host from a string. + + Args: + s (str): String representation of the resubmission host. + + Returns: + ResubmitHost: The corresponding `ResubmitHost` variant. + """ + match s.lower().strip(): + case "input": + return InputHost() + case "working" | "work": + return WorkHost() + case _: + return ExplicitHost(s.strip()) + + @classmethod + def multi_from_str(cls, raw: str) -> list["ResubmitHost"]: + """ + Parses multiple resubmission hosts from a delimited string. + + Args: + raw (str): String containing one or more host specifiers separated by + colons, commas, or spaces. + Examples: "input:node132.random.server.org", "work,input", or "node123 node234". + + Returns: + list[ResubmitHost]: A list of parsed `ResubmitHost` instances. + """ + host_strings = re.split(r"[:,\s]+", raw.strip()) + host_strings = [hs for hs in host_strings if hs] + + return [ResubmitHost.from_str(host_str) for host_str in host_strings] + + @abstractmethod + def resolve(self, input_host: str, working_node: str) -> str: + """ + Resolves this resubmission host to a concrete hostname. + + Args: + input_host (str): The name of the machine from which the job was originally submitted. + working_node (str): The name of the node on which the job is currently running. + For multi-node jobs, use the main node. + + Returns: + str: The resolved hostname string. + """ + + +@dataclass(frozen=True) +class InputHost(ResubmitHost): + """ + A resubmission host that resolves to the original input machine. + """ + + def to_str(self) -> str: + return "input" + + def resolve(self, input_host: str, working_node: str) -> str: + _ = working_node + return input_host + + +@dataclass(frozen=True) +class WorkHost(ResubmitHost): + """ + A resubmission host that resolves to the current working node. + """ + + def to_str(self) -> str: + return "working" + + def resolve(self, input_host: str, working_node: str) -> str: + _ = input_host + return working_node + + +@dataclass(frozen=True) +class ExplicitHost(ResubmitHost): + """ + A resubmission host that stores an explicit hostname. + + Attributes: + hostname: The explicit hostname to use for resubmission. + """ + + hostname: str + + def to_str(self) -> str: + return self.hostname + + def resolve(self, input_host: str, working_node: str) -> str: + _ = input_host, working_node + return self.to_str() diff --git a/src/qq_lib/run/resubmitter.py b/src/qq_lib/run/resubmitter.py new file mode 100644 index 0000000..ee6a258 --- /dev/null +++ b/src/qq_lib/run/resubmitter.py @@ -0,0 +1,158 @@ +# Released under MIT License. +# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab + +from pathlib import Path + +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.core.operator import Operator +from qq_lib.core.retryer import Retryer +from qq_lib.info import Informer +from qq_lib.properties.depend import Depend, DependType +from qq_lib.properties.resubmit_host import ResubmitHost +from qq_lib.submit import Submitter + +logger = get_logger(__name__, show_time=True) + + +class Resubmitter(Operator): + """ + Handles resubmission of loop and continuous jobs. + """ + + def resubmit(self) -> str: + """ + Resubmit the job to the next cycle. + + Returns: + str: The job ID of the newly submitted job. + + Raises: + QQError: If the main node is not defined or if resubmission fails + on all candidate hosts. + """ + informer = self.get_informer() + input_dir = self._info_file.parent + + # we set the current cycle manually instead of determining it from the archive + # this means that the job will always be resubmitted to the "correct" (expected) cycle, + # but may fail after starting if the archive does not contain files for this cycle + self._advance_loop_cycle(informer) + + submitter = self._build_submitter(informer, input_dir) + hosts = self._resolve_resubmit_hosts(informer) + + return self._try_resubmit(submitter, informer, hosts) + + @staticmethod + def _advance_loop_cycle(informer: Informer) -> None: + """ + Advance the loop cycle counter if the job is a loop job. + + Args: + informer: The informer instance holding job metadata. + """ + if informer.info.loop_info: + informer.info.loop_info.current += 1 + + @staticmethod + def _build_submitter(informer: Informer, input_dir: Path) -> Submitter: + """ + Construct a Submitter configured for resubmission. + + All original job parameters are preserved, except dependencies are replaced + with a single dependency on the current job. This is because the previous + cycle has already run, so its original dependencies must have been satisfied. + + Args: + informer (Informer): The informer instance holding job metadata. + input_dir (Path): Path to the directory containing the job's input files. + + Returns: + Submitter: A configured submitter ready to submit the job. + """ + return Submitter( + batch_system=informer.batch_system, + queue=informer.info.queue, + account=informer.info.account, + script=input_dir / informer.info.script_name, + job_type=informer.info.job_type, + resources=informer.info.resources, + loop_info=informer.info.loop_info, + exclude=informer.info.excluded_files, + include=informer.info.included_files, + depend=[Depend(type=DependType.AFTER_SUCCESS, jobs=[informer.info.job_id])], + transfer_mode=informer.info.transfer_mode, + server=informer.info.server, + interpreter=informer.info.interpreter, + ) + + @staticmethod + def _resolve_resubmit_hosts(informer: Informer) -> list[ResubmitHost]: + """ + Determine the list of hosts to attempt resubmission from. + + Hosts are resolved using a three-tier fallback: + 1. Hosts explicitly specified in the job metadata. + 2. Default hosts from the configuration file. + 3. Default hosts for the batch system. + + Args: + informer (Informer): The informer instance holding job metadata. + + Returns: + list[ResubmitHost]: Ordered list of candidate resubmission hosts. + """ + hosts = informer.info.resubmit_from + if not hosts: + if raw_hosts := CFG.resubmitter.default_resubmit_hosts: + hosts = ResubmitHost.multi_from_str(raw_hosts) + else: + hosts = informer.batch_system.get_default_resubmit_hosts() + + return hosts + + @staticmethod + def _try_resubmit( + submitter: Submitter, + informer: Informer, + hosts: list[ResubmitHost], + ) -> str: + """ + Attempt resubmission on each candidate host in order. + + Args: + submitter (Submitter): The configured submitter to use for job submission. + informer (Informer): The informer instance holding job metadata. + hosts (list[ResubmitHost]): Ordered list of candidate resubmission hosts to try. + + Returns: + str: The job ID of the newly submitted job. + + Raises: + QQError: If the main node is not defined in the job metadata. + QQError: If resubmission fails on all candidate hosts. + """ + # get the main node for host resolution + # since the job should be running, the main node should be defined + main_node = informer.info.main_node + if not main_node: + raise QQError( + "Job cannot be resubmitted. The 'main_node' of the job is not defined." + ) + + for host in hosts: + hostname = host.resolve(informer.info.input_machine, main_node) + logger.info(f"Resubmitting from host '{hostname}'.") + try: + return Retryer( + submitter.submit, + remote=hostname, + max_tries=CFG.resubmitter.retry_tries, + wait_seconds=CFG.resubmitter.retry_wait, + ).run() + except Exception as e: + logger.warning(f"Failed resubmission from host '{hostname}': {e}") + + raise QQError("Could not resubmit the job.") diff --git a/src/qq_lib/run/runner.py b/src/qq_lib/run/runner.py index e314d7f..40d33e1 100644 --- a/src/qq_lib/run/runner.py +++ b/src/qq_lib/run/runner.py @@ -30,6 +30,7 @@ from qq_lib.info.informer import Informer from qq_lib.properties.job_type import JobType from qq_lib.properties.states import NaiveState +from qq_lib.run.resubmitter import Resubmitter logger = get_logger(__name__, show_time=True) @@ -716,20 +717,10 @@ def _resubmit(self) -> None: return logger.info("Resubmitting the job.") - logger.debug( - f"Resubmitting using the batch system '{str(self._batch_system)}'." - ) - - Retryer( - self._batch_system.resubmit, - input_machine=self._informer.info.input_machine, - input_dir=self._informer.info.input_dir, - command_line=self._informer.info.get_command_line_for_resubmit(), - max_tries=CFG.runner.retry_tries, - wait_seconds=CFG.runner.retry_wait, - ).run() + resubmitter = Resubmitter.from_informer(self._informer) + job_id = resubmitter.resubmit() - logger.info("Job successfully resubmitted.") + logger.info(f"Job resubmitted successfully as '{job_id}'.") def _get_explicitly_included_files_in_work_dir(self) -> list[Path]: """ diff --git a/tests/test_batch_slurm_slurm.py b/tests/test_batch_slurm_slurm.py index 7279308..53b0b70 100644 --- a/tests/test_batch_slurm_slurm.py +++ b/tests/test_batch_slurm_slurm.py @@ -542,20 +542,6 @@ def test_slurm_is_shared_delegates_to_interface(mock_is_shared): assert result is True -@patch("qq_lib.batch.slurm.slurm.BatchInterface.resubmit") -def test_slurm_resubmit_delegates_to_interface(mock_resubmit): - Slurm.resubmit( - input_machine="machine1", - input_dir=Path("/work/job"), - command_line=["-q gpu", "--account fake-account"], - ) - mock_resubmit.assert_called_once_with( - input_machine="machine1", - input_dir=Path("/work/job"), - command_line=["-q gpu", "--account fake-account"], - ) - - @patch("qq_lib.batch.slurm.slurm.PBS.read_remote_file", return_value="content") def test_slurm_read_remote_file_delegates(mock_read): result = Slurm.read_remote_file("host1", Path("/tmp/file.txt")) diff --git a/tests/test_batch_slurmit4i_slurm.py b/tests/test_batch_slurmit4i_slurm.py index 5469772..7923ec3 100644 --- a/tests/test_batch_slurmit4i_slurm.py +++ b/tests/test_batch_slurmit4i_slurm.py @@ -84,43 +84,6 @@ def test_slurmit4i_get_default_server_resources_returns_empty_on_failure( assert result == Resources() -@patch("qq_lib.batch.slurmit4i.slurm.subprocess.run") -@patch("qq_lib.batch.slurmit4i.slurm.os.chdir") -def test_slurmit4i_resubmit_success(mock_chdir, mock_run): - mock_run.return_value = MagicMock(returncode=0) - SlurmIT4I.resubmit( - input_machine="unused_machine", - input_dir=Path("/home/user/jobdir"), - command_line=["-q", "default"], - ) - mock_chdir.assert_called_once_with(Path("/home/user/jobdir")) - mock_run.assert_called_once() - - -@patch("qq_lib.batch.slurmit4i.slurm.os.chdir", side_effect=OSError("failed to cd")) -def test_slurmit4i_resubmit_raises_when_cannot_cd(mock_chdir): - with pytest.raises(QQError, match="Could not navigate to"): - SlurmIT4I.resubmit( - input_machine="unused_machine", - input_dir=Path("/home/user/jobdir"), - command_line=["-q", "default"], - ) - mock_chdir.assert_called_once_with(Path("/home/user/jobdir")) - - -@patch("qq_lib.batch.slurmit4i.slurm.subprocess.run") -@patch("qq_lib.batch.slurmit4i.slurm.os.chdir") -def test_slurmit4i_resubmit_raises_when_command_fails(mock_chdir, mock_run): - mock_run.return_value = MagicMock(returncode=1, stderr="execution failed") - with pytest.raises(QQError): - SlurmIT4I.resubmit( - input_machine="unused_machine", - input_dir=Path("/home/user/jobdir"), - command_line=["-q", "default"], - ) - mock_chdir.assert_called_once_with(Path("/home/user/jobdir")) - - def test_slurmit4i_is_shared_returns_true(): assert SlurmIT4I.is_shared(Path.cwd()) diff --git a/tests/test_properties_resubmit_host.py b/tests/test_properties_resubmit_host.py new file mode 100644 index 0000000..1e72d0d --- /dev/null +++ b/tests/test_properties_resubmit_host.py @@ -0,0 +1,129 @@ +# Released under MIT License. +# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab + + +import pytest + +from qq_lib.properties.resubmit_host import ( + ExplicitHost, + InputHost, + ResubmitHost, + WorkHost, +) + + +@pytest.mark.parametrize( + "input_str, expected_type", + [ + ("input", InputHost), + ("INPUT", InputHost), + (" input ", InputHost), + ("working", WorkHost), + ("work", WorkHost), + ("WORKING", WorkHost), + (" work ", WorkHost), + ], +) +def test_resubmit_host_from_str_keyword_mappings(input_str, expected_type): + result = ResubmitHost.from_str(input_str) + assert isinstance(result, expected_type) + + +@pytest.mark.parametrize( + "input_str, expected_hostname", + [ + ("node001", "node001"), + ("node132.random.server.org", "node132.random.server.org"), + (" node001 ", "node001"), + ("my-host", "my-host"), + ("192.168.1.1", "192.168.1.1"), + ], +) +def test_resubmit_host_from_str_explicit_hosts(input_str, expected_hostname): + result = ResubmitHost.from_str(input_str) + assert isinstance(result, ExplicitHost) + assert result.hostname == expected_hostname + + +@pytest.mark.parametrize( + "raw, expected_types", + [ + ("input:node132", [InputHost, ExplicitHost]), + ("work,input", [WorkHost, InputHost]), + ("node123 node234", [ExplicitHost, ExplicitHost]), + ("input:working:node001", [InputHost, WorkHost, ExplicitHost]), + ("input,working,node001", [InputHost, WorkHost, ExplicitHost]), + ("input working node001", [InputHost, WorkHost, ExplicitHost]), + ("input, working, node001", [InputHost, WorkHost, ExplicitHost]), + ("input : working : node001", [InputHost, WorkHost, ExplicitHost]), + ], +) +def test_multi_from_str_valid_inputs(raw, expected_types): + hosts = ResubmitHost.multi_from_str(raw) + + assert len(hosts) == len(expected_types) + for host, expected_type in zip(hosts, expected_types): + assert isinstance(host, expected_type) + + +@pytest.mark.parametrize( + "raw, expected_count", + [ + ("input:::working", 2), + ("input,,,working", 2), + (" input working ", 2), + (" input ", 1), + (" input, ", 1), + ], +) +def test_multi_from_str_robust_splitting(raw, expected_count): + hosts = ResubmitHost.multi_from_str(raw) + assert len(hosts) == expected_count + assert all(isinstance(h, ResubmitHost) for h in hosts) + + +@pytest.mark.parametrize( + "raw", + [ + "", + " ", + ",,,", + "::::", + " , : ,", + ], +) +def test_multi_from_str_empty_or_whitespace_input(raw): + assert ResubmitHost.multi_from_str(raw) == [] + + +def test_input_host_to_str(): + assert InputHost().to_str() == "input" + + +def test_input_host_resolve(): + host = InputHost() + assert ( + host.resolve("submit-node.example.com", "compute-node-01") + == "submit-node.example.com" + ) + + +def test_work_host_to_str(): + assert WorkHost().to_str() == "working" + + +def test_work_host_resolve(): + host = WorkHost() + assert ( + host.resolve("submit-node.example.com", "compute-node-01") == "compute-node-01" + ) + + +def test_explicit_host_to_str(): + host = ExplicitHost("node132.random.server.org") + assert host.to_str() == "node132.random.server.org" + + +def test_explicit_host_resolve_ignores_both_arguments(): + host = ExplicitHost("my-explicit-host") + assert host.resolve("submit-node", "compute-node") == "my-explicit-host" diff --git a/tests/test_run_resubmitter.py b/tests/test_run_resubmitter.py new file mode 100644 index 0000000..cfb6cd9 --- /dev/null +++ b/tests/test_run_resubmitter.py @@ -0,0 +1,263 @@ +# Released under MIT License. +# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab + + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from qq_lib.core.error import QQError +from qq_lib.properties.depend import Depend, DependType +from qq_lib.properties.resubmit_host import ( + ExplicitHost, + InputHost, + ResubmitHost, + WorkHost, +) +from qq_lib.run.resubmitter import Resubmitter + + +def test_resubmitter_advance_loop_cycle_increments_when_loop_info_exists(): + informer = MagicMock() + informer.info.loop_info.current = 5 + + Resubmitter._advance_loop_cycle(informer) + + assert informer.info.loop_info.current == 6 + + +def test_resubmitter_advance_loop_cycle_does_nothing_when_no_loop_info(): + informer = MagicMock() + informer.info.loop_info = None + + Resubmitter._advance_loop_cycle(informer) + + assert informer.info.loop_info is None + + +def test_resubmitter_build_submitter_creates_submitter_with_correct_params(): + informer = MagicMock() + informer.info.job_id = "12345" + input_dir = Path("/tmp/input") + + with patch("qq_lib.run.resubmitter.Submitter") as mock_submitter_cls: + Resubmitter._build_submitter(informer, input_dir) + + mock_submitter_cls.assert_called_once_with( + batch_system=informer.batch_system, + queue=informer.info.queue, + account=informer.info.account, + script=input_dir / informer.info.script_name, + job_type=informer.info.job_type, + resources=informer.info.resources, + loop_info=informer.info.loop_info, + exclude=informer.info.excluded_files, + include=informer.info.included_files, + depend=[Depend(type=DependType.AFTER_SUCCESS, jobs=["12345"])], + transfer_mode=informer.info.transfer_mode, + server=informer.info.server, + interpreter=informer.info.interpreter, + ) + + +def test_resubmitter_build_submitter_depends_on_current_job(): + informer = MagicMock() + informer.info.job_id = "99999" + input_dir = Path("/tmp/input") + + with patch("qq_lib.run.resubmitter.Submitter") as mock_submitter_cls: + Resubmitter._build_submitter(informer, input_dir) + + call_kwargs = mock_submitter_cls.call_args.kwargs + assert len(call_kwargs["depend"]) == 1 + assert call_kwargs["depend"][0].type == DependType.AFTER_SUCCESS + assert call_kwargs["depend"][0].jobs == ["99999"] + + +def test_resubmitter_build_submitter_constructs_script_path(): + informer = MagicMock() + informer.info.script_name = "run.sh" + input_dir = Path("/home/user/jobs") + + with patch("qq_lib.run.resubmitter.Submitter") as mock_submitter_cls: + Resubmitter._build_submitter(informer, input_dir) + + call_kwargs = mock_submitter_cls.call_args.kwargs + assert call_kwargs["script"] == Path("/home/user/jobs/run.sh") + + +def test_resubmitter_resolve_resubmit_hosts_uses_job_metadata_when_available(): + informer = MagicMock() + informer.info.resubmit_from = [InputHost(), WorkHost()] + + result = Resubmitter._resolve_resubmit_hosts(informer) + + assert result == [InputHost(), WorkHost()] + + +def test_resubmitter_resolve_resubmit_hosts_falls_back_to_config(): + informer = MagicMock() + informer.info.resubmit_from = [] + + with patch("qq_lib.run.resubmitter.CFG") as mock_cfg: + mock_cfg.resubmitter.default_resubmit_hosts = "input:working" + result = Resubmitter._resolve_resubmit_hosts(informer) + + assert result == [InputHost(), WorkHost()] + + +def test_resubmitter_resolve_resubmit_hosts_falls_back_to_batch_system(): + informer = MagicMock() + informer.info.resubmit_from = [] + default_hosts = [InputHost(), WorkHost()] + informer.batch_system.get_default_resubmit_hosts.return_value = default_hosts + + with patch("qq_lib.run.resubmitter.CFG") as mock_cfg: + mock_cfg.resubmitter.default_resubmit_hosts = "" + result = Resubmitter._resolve_resubmit_hosts(informer) + + assert result is default_hosts + informer.batch_system.get_default_resubmit_hosts.assert_called_once() + + +def test_resubmitter_try_resubmit_returns_job_id_on_success(): + submitter = MagicMock() + informer = MagicMock() + informer.info.main_node = "main-node-01" + informer.info.input_machine = "submit-node" + hosts: list[ResubmitHost] = [InputHost()] + + with patch("qq_lib.run.resubmitter.Retryer") as mock_retryer_cls: + mock_retryer_cls.return_value.run.return_value = "67890" + result = Resubmitter._try_resubmit(submitter, informer, hosts) + + assert result == "67890" + + +def test_resubmitter_try_resubmit_raises_when_main_node_not_defined(): + submitter = MagicMock() + informer = MagicMock() + informer.info.main_node = "" + hosts: list[ResubmitHost] = [InputHost()] + + with pytest.raises(QQError, match="main_node"): + Resubmitter._try_resubmit(submitter, informer, hosts) + + +def test_resubmitter_try_resubmit_tries_next_host_on_failure(): + submitter = MagicMock() + informer = MagicMock() + informer.info.main_node = "main-node-01" + informer.info.input_machine = "submit-node" + hosts: list[ResubmitHost] = [InputHost(), ExplicitHost("fallback-node")] + + with patch("qq_lib.run.resubmitter.Retryer") as mock_retryer_cls: + mock_retryer_cls.return_value.run.side_effect = [ + RuntimeError("connection refused"), + "99999", + ] + result = Resubmitter._try_resubmit(submitter, informer, hosts) + + assert result == "99999" + assert mock_retryer_cls.return_value.run.call_count == 2 + + +def test_resubmitter_try_resubmit_raises_when_all_hosts_fail(): + submitter = MagicMock() + informer = MagicMock() + informer.info.main_node = "main-node-01" + informer.info.input_machine = "submit-node" + hosts: list[ResubmitHost] = [InputHost(), WorkHost()] + + with ( + patch("qq_lib.run.resubmitter.Retryer") as mock_retryer_cls, + pytest.raises(QQError, match="Could not resubmit the job"), + ): + mock_retryer_cls.return_value.run.side_effect = RuntimeError("failed") + Resubmitter._try_resubmit(submitter, informer, hosts) + + +def test_resubmitter_try_resubmit_resolves_hostnames_correctly(): + submitter = MagicMock() + informer = MagicMock() + informer.info.main_node = "compute-01" + informer.info.input_machine = "login-01" + hosts: list[ResubmitHost] = [InputHost(), WorkHost(), ExplicitHost("explicit-01")] + + with patch("qq_lib.run.resubmitter.Retryer") as mock_retryer_cls: + mock_retryer_cls.return_value.run.side_effect = [ + RuntimeError("fail"), + RuntimeError("fail"), + "11111", + ] + Resubmitter._try_resubmit(submitter, informer, hosts) + + calls = mock_retryer_cls.call_args_list + assert calls[0].kwargs["remote"] == "login-01" + assert calls[1].kwargs["remote"] == "compute-01" + assert calls[2].kwargs["remote"] == "explicit-01" + + +def test_resubmitter_try_resubmit_uses_configured_retry_params(): + submitter = MagicMock() + informer = MagicMock() + informer.info.main_node = "main-node-01" + informer.info.input_machine = "submit-node" + hosts: list[ResubmitHost] = [InputHost()] + + with ( + patch("qq_lib.run.resubmitter.Retryer") as mock_retryer_cls, + patch("qq_lib.run.resubmitter.CFG") as mock_cfg, + ): + mock_cfg.resubmitter.retry_tries = 5 + mock_cfg.resubmitter.retry_wait = 30 + mock_retryer_cls.return_value.run.return_value = "12345" + + Resubmitter._try_resubmit(submitter, informer, hosts) + + mock_retryer_cls.assert_called_once_with( + submitter.submit, + remote="submit-node", + max_tries=5, + wait_seconds=30, + ) + + +def test_try_resubmit_returns_on_first_success(): + submitter = MagicMock() + informer = MagicMock() + informer.info.main_node = "main-node-01" + informer.info.input_machine = "submit-node" + hosts: list[ResubmitHost] = [InputHost(), WorkHost(), ExplicitHost("node-03")] + + with patch("qq_lib.run.resubmitter.Retryer") as mock_retryer_cls: + mock_retryer_cls.return_value.run.return_value = "11111" + result = Resubmitter._try_resubmit(submitter, informer, hosts) + + assert result == "11111" + assert mock_retryer_cls.return_value.run.call_count == 1 + + +def test_resubmit_calls_methods_in_order(): + resubmitter = Resubmitter.__new__(Resubmitter) + resubmitter._info_file = Path("/tmp/input/job.info") + + informer = MagicMock() + resubmitter.get_informer = MagicMock(return_value=informer) + + with ( + patch.object(Resubmitter, "_advance_loop_cycle") as mock_advance, + patch.object(Resubmitter, "_build_submitter") as mock_build, + patch.object(Resubmitter, "_resolve_resubmit_hosts") as mock_resolve, + patch.object(Resubmitter, "_try_resubmit", return_value="12345") as mock_try, + ): + result = resubmitter.resubmit() + + assert result == "12345" + mock_advance.assert_called_once_with(informer) + mock_build.assert_called_once_with(informer, Path("/tmp/input")) + mock_resolve.assert_called_once_with(informer) + mock_try.assert_called_once_with( + mock_build.return_value, informer, mock_resolve.return_value + ) diff --git a/tests/test_run_runner.py b/tests/test_run_runner.py index 8894312..e6fd282 100644 --- a/tests/test_run_runner.py +++ b/tests/test_run_runner.py @@ -376,23 +376,20 @@ def test_runner_resubmit_successful_resubmission(): retryer_mock = MagicMock() retryer_mock.run.return_value = None + resubmitter_mock = MagicMock() + resubmitter_mock.resubmit.return_value = "12345" + with ( patch("qq_lib.run.runner.logger") as mock_logger, - patch("qq_lib.run.runner.Retryer", return_value=retryer_mock) as mock_retryer, + patch("qq_lib.run.runner.Resubmitter") as mock_resubmitter_cls, ): + mock_resubmitter_cls.from_informer.return_value = resubmitter_mock runner._resubmit() mock_logger.info.assert_any_call("Resubmitting the job.") - mock_retryer.assert_called_once_with( - runner._batch_system.resubmit, - input_machine="random.host.org", - input_dir="/dir", - command_line=["cmd"], - max_tries=CFG.runner.retry_tries, - wait_seconds=CFG.runner.retry_wait, - ) - retryer_mock.run.assert_called_once() - mock_logger.info.assert_any_call("Job successfully resubmitted.") + mock_resubmitter_cls.from_informer.assert_called_once_with(runner._informer) + resubmitter_mock.resubmit.assert_called_once() + mock_logger.info.assert_any_call("Job resubmitted successfully as '12345'.") def test_runner_resubmit_raises_qqerror(): @@ -410,7 +407,10 @@ def test_runner_resubmit_raises_qqerror(): runner._should_resubmit = True with ( - patch("qq_lib.run.runner.Retryer", side_effect=QQError("resubmit failed")), + patch( + "qq_lib.run.runner.Resubmitter.resubmit", + side_effect=QQError("resubmit failed"), + ), patch("qq_lib.run.runner.logger"), pytest.raises(QQError, match="resubmit failed"), ): @@ -1083,6 +1083,9 @@ def test_runner_finalize_with_scratch_archiver_and_resubmit(mock_logger_info): runner._update_info_finished = MagicMock() runner._resubmit = MagicMock() + resubmitter_mock = MagicMock() + resubmitter_mock.resubmit.return_value = "12345" + with ( patch("qq_lib.run.runner.Retryer") as retryer_mock, patch("socket.getfqdn", return_value="host"), From f2a4221c43de48e44d574b4f4591442bea1e9216 Mon Sep 17 00:00:00 2001 From: Ladme Date: Thu, 7 May 2026 14:32:46 +0200 Subject: [PATCH 18/43] Handling exceptions at config reading --- CHANGELOG.md | 1 + src/qq_lib/core/config.py | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba6817a..0207732 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Refactored BatchMeta. - Jobs can be now submitted from a remote machine. - Submitting jobs using `Submitter.submit` is now thread-safe. +- Handling exceptions that occur when reading a configuration file. *** diff --git a/src/qq_lib/core/config.py b/src/qq_lib/core/config.py index 5732457..d094a38 100644 --- a/src/qq_lib/core/config.py +++ b/src/qq_lib/core/config.py @@ -13,6 +13,7 @@ """ import os +import sys import tomllib from dataclasses import dataclass, field, fields, is_dataclass from pathlib import Path @@ -570,4 +571,8 @@ def _dict_to_dataclass(cls, data: dict[str, Any]): # Global configuration for qq. -CFG = Config.load() +try: + CFG = Config.load() +except Exception as e: + print(f"[ FATAL CONFIGURATION ERROR ] Could not read qq config file: {e}") + sys.exit(91) From 27cb3055c3ae3ac450e0c5513255a051c3f5ba7e Mon Sep 17 00:00:00 2001 From: Ladme Date: Thu, 7 May 2026 16:06:31 +0200 Subject: [PATCH 19/43] Resubmit from option --- src/qq_lib/batch/pbs/pbs.py | 19 ++-- src/qq_lib/batch/slurm/slurm.py | 5 +- src/qq_lib/core/common.py | 15 +++ src/qq_lib/properties/info.py | 66 ++----------- src/qq_lib/properties/resources.py | 30 ------ src/qq_lib/properties/resubmit_host.py | 9 ++ src/qq_lib/run/resubmitter.py | 1 + src/qq_lib/submit/cli.py | 21 +++- src/qq_lib/submit/factory.py | 21 ++++ src/qq_lib/submit/parser.py | 13 +++ src/qq_lib/submit/submitter.py | 11 +++ tests/test_batch_pbs_pbs.py | 42 ++++++-- tests/test_batch_slurm_slurm.py | 4 +- tests/test_properties_info.py | 128 ++----------------------- tests/test_properties_loop.py | 44 --------- tests/test_properties_resources.py | 65 ------------- tests/test_run_resubmitter.py | 1 + tests/test_submit_factory.py | 46 +++++++++ tests/test_submit_parser.py | 24 +++++ tests/test_submit_submitter.py | 9 ++ 20 files changed, 241 insertions(+), 333 deletions(-) diff --git a/src/qq_lib/batch/pbs/pbs.py b/src/qq_lib/batch/pbs/pbs.py index 73f0ad4..b4651c5 100644 --- a/src/qq_lib/batch/pbs/pbs.py +++ b/src/qq_lib/batch/pbs/pbs.py @@ -79,7 +79,10 @@ def job_submit( # account unused _ = account - cls._shared_guard(res, env_vars, server, remote_host) + input_dir = script.parent + logger.debug(f"Job submission: input directory is '{str(input_dir)}'.") + + cls._shared_guard(input_dir, res, env_vars, server, remote_host) # set env vars required for Infinity modules # this can be removed once Infinity stops being supported @@ -498,6 +501,7 @@ def _get_scratch_dir(cls, job_id: str) -> Path: @classmethod def _shared_guard( cls, + input_dir: Path, res: Resources, env_vars: dict[str, str], server: str | None, @@ -506,22 +510,25 @@ def _shared_guard( """ Ensure correct handling of shared vs. local submission directories. - If the current working directory is on shared storage, adds the + If the job's input directory is on shared storage, adds the environment variable `SHARED_SUBMIT` to the list of env vars to propagate to the job. This environment variable is later used e.g. to select the appropriate data copying method. If the job is configured to use the submission directory as a working directory (`work-dir=input_dir` or 'job_dir') but that directory is not shared, a `QQError` is raised. - If the job is to be submitted to a potentially non-local server - but the directory is not shared, a `QQError` is raised. + If the job is to be submitted to a potentially non-local server, + but the input directory is not shared, a `QQError` is raised. - If the job is to be submitted on a remote host, but the directory is not shared, a `QQError` is raised. + If the job is to be submitted on a remote host, + but the input directory is not shared, a `QQError` is raised. Args: + input_dir (Path): Path to the input directory of the job. res (Resources): The job's resource configuration. env_vars (dict[str, str]): Dictionary of environment variables to propagate to the job. server (str | None): The target PBS server, or None if submitting to the default server. + remote_host (str | None): Host from which the submission is performed, or None if sumitting from the current machine. Raises: QQError: If the job is set to run directly in the submission @@ -529,7 +536,7 @@ def _shared_guard( QQError: If the job is set to run on a non-default server while submission is from a non-shared filesystem. """ - if cls.is_shared(Path()): + if cls.is_shared(input_dir): env_vars[CFG.env_vars.shared_submit] = "true" elif not res.uses_scratch(): # if job directory is used as working directory, it must always be shared diff --git a/src/qq_lib/batch/slurm/slurm.py b/src/qq_lib/batch/slurm/slurm.py index 7a703b4..4018842 100644 --- a/src/qq_lib/batch/slurm/slurm.py +++ b/src/qq_lib/batch/slurm/slurm.py @@ -65,8 +65,11 @@ def job_submit( if server: logger.warning("The 'server' option is ignored for Slurm.") + input_dir = script.parent + logger.debug(f"Job submission: input directory is '{str(input_dir)}'.") + # intentionally using PBS - PBS._shared_guard(res, env_vars, server, remote_host) + PBS._shared_guard(input_dir, res, env_vars, server, remote_host) command = cls._translate_submit( res, queue, script.parent, str(script), job_name, depend, env_vars, account diff --git a/src/qq_lib/core/common.py b/src/qq_lib/core/common.py index f439d6f..ba856aa 100644 --- a/src/qq_lib/core/common.py +++ b/src/qq_lib/core/common.py @@ -747,3 +747,18 @@ def translate_server(raw: str) -> str: str: Full name the the batch server. """ return CFG.batch_servers_options.known_servers.get(raw, raw) + + +def default_resubmit_from_hosts() -> str: + """ + Returns the default resubmission hosts as a string. + + Returns: + str: A comma-separated list of the default resubmission hosts. + """ + from qq_lib.batch.interface import BatchInterface + + return CFG.resubmitter.default_resubmit_hosts or ",".join( + x.to_str() + for x in BatchInterface.from_env_var_or_guess().get_default_resubmit_hosts() + ) diff --git a/src/qq_lib/properties/info.py b/src/qq_lib/properties/info.py index c8a638a..9a44ff2 100644 --- a/src/qq_lib/properties/info.py +++ b/src/qq_lib/properties/info.py @@ -121,7 +121,7 @@ class Info: server: str | None = None # Hosts from which a loop job or a continuous job should be resubmitted - resubmit_from: list[ResubmitHost] | None = None + resubmit_from: list[ResubmitHost] = field(default_factory=list) # Interpreter to use for running the submitted script interpreter: str | None = None @@ -230,57 +230,6 @@ def to_file(self, file: Path, host: str | None = None) -> None: except Exception as e: raise QQError(f"Cannot create or write to file '{file}': {e}") from e - def get_command_line_for_resubmit(self) -> list[str]: - """ - Construct the command-line arguments required to resubmit the job. - - Returns: - list[str]: A list of command-line tokens representing all options - needed to resubmit the job. - """ - - command_line = [ - self.script_name, - "--queue", - self.queue, - "--job-type", - str(self.job_type), - "--batch-system", - str(self.batch_system), - "--depend", - f"afterok={self.job_id}", - ] - - command_line.extend(self.resources.to_command_line()) - - if self.server: - command_line.extend(["--server", self.server]) - - if self.account: - command_line.extend(["--account", self.account]) - - if self.excluded_files: - command_line.extend( - ["--exclude", ",".join([str(x) for x in self.excluded_files])] - ) - - if self.included_files: - command_line.extend( - ["--include", ",".join([str(x) for x in self.included_files])] - ) - - if self.loop_info: - command_line.extend(self.loop_info.to_command_line()) - - command_line.extend( - [ - "--transfer-mode", - ":".join(mode.to_str() for mode in self.transfer_mode), - ] - ) - - return command_line - def _to_yaml(self) -> str: """ Serialize the Info instance to a YAML string. @@ -330,11 +279,13 @@ def _to_dict(self) -> dict[str, object]: # convert list of excluded/included files elif f.type == list[Path]: result[f.name] = [str(x) for x in value] - # conver transfer modes - elif f.type == list[TransferMode]: + # convert transfer modes, resubmit hosts, and dependencies + elif ( + f.type == list[TransferMode] + or f.type == list[ResubmitHost] + or f.type == list[Depend] + ): result[f.name] = [x.to_str() for x in value] - elif f.type == list[Depend]: - result[f.name] = [Depend.to_str(x) for x in value] # convert timestamp elif f.type == datetime or f.type == datetime | None: result[f.name] = value.strftime(CFG.date_formats.standard) @@ -397,6 +348,9 @@ def _from_dict(cls, data: dict[str, object]) -> Self: # convert dependencies elif f.type == list[Depend] and isinstance(value, list): init_kwargs[name] = [Depend.from_str(x) for x in value] # ty: ignore[invalid-argument-type] + # convert resubmit hosts + elif f.type == list[ResubmitHost] and isinstance(value, list): + init_kwargs[name] = [ResubmitHost.from_str(x) for x in value] # ty: ignore[invalid-argument-type] # convert timestamp elif (f.type == datetime or f.type == datetime | None) and isinstance( value, str diff --git a/src/qq_lib/properties/resources.py b/src/qq_lib/properties/resources.py index 20e4f17..a96db83 100644 --- a/src/qq_lib/properties/resources.py +++ b/src/qq_lib/properties/resources.py @@ -218,36 +218,6 @@ def merge_resources(*resources: "Resources") -> "Resources": return Resources(**merged_data) - def to_command_line(self) -> list[str]: - """ - Convert resource settings into a command-line argument list for `qq submit`. - - Returns: - list[str]: A list of command-line arguments ready to pass to ``qq submit``. - """ - command_line: list[str] = [] - for f in fields(Resources): - field_name = f.name.replace("_", "-") - value = getattr(self, f.name) - if value is None: - continue - - if isinstance(value, Size): - command_line.extend([f"--{field_name}", value.to_str_exact()]) - elif isinstance(value, int): - command_line.extend([f"--{field_name}", str(value)]) - elif isinstance(value, dict): - if value := self._props_to_value(): - command_line.extend([f"--{field_name}", value]) - elif isinstance(value, str): - command_line.extend([f"--{field_name}", value]) - else: - raise QQError( - f"Unknown value type detected: {field_name}={value} of type {type(value)} when converting Resources to command line options. This is a bug, please report this." - ) - - return command_line - @staticmethod def _parse_size(value: object) -> Size | None: """ diff --git a/src/qq_lib/properties/resubmit_host.py b/src/qq_lib/properties/resubmit_host.py index 71a6243..4a0ca3e 100644 --- a/src/qq_lib/properties/resubmit_host.py +++ b/src/qq_lib/properties/resubmit_host.py @@ -59,6 +59,15 @@ def multi_from_str(cls, raw: str) -> list["ResubmitHost"]: return [ResubmitHost.from_str(host_str) for host_str in host_strings] + @abstractmethod + def to_str(self) -> str: + """ + Convert the resubmission host into its string representation. + + Returns: + str: String, unresolved representation of the resubmission host. + """ + @abstractmethod def resolve(self, input_host: str, working_node: str) -> str: """ diff --git a/src/qq_lib/run/resubmitter.py b/src/qq_lib/run/resubmitter.py index ee6a258..b66c766 100644 --- a/src/qq_lib/run/resubmitter.py +++ b/src/qq_lib/run/resubmitter.py @@ -86,6 +86,7 @@ def _build_submitter(informer: Informer, input_dir: Path) -> Submitter: transfer_mode=informer.info.transfer_mode, server=informer.info.server, interpreter=informer.info.interpreter, + resubmit_from=informer.info.resubmit_from, ) @staticmethod diff --git a/src/qq_lib/submit/cli.py b/src/qq_lib/submit/cli.py index ad88551..8daf8d1 100644 --- a/src/qq_lib/submit/cli.py +++ b/src/qq_lib/submit/cli.py @@ -14,6 +14,7 @@ from qq_lib.core.common import ( available_job_types, available_work_dirs, + default_resubmit_from_hosts, get_runtime_files, ) from qq_lib.core.config import CFG @@ -254,7 +255,25 @@ def complete_script( help="Colon-, comma-, or space-separated list of node properties required (e.g., cl_two) or prohibited (e.g., ^cl_two) to run the job.", ) @optgroup.group( - f"{click.style('Loop options', fg='yellow')}", + f"{click.style('Settings for loop and continuous jobs', fg='yellow')}", + help=f"Only used when job-type is {click.style('loop', bold=True)} or {click.style('continuous', bold=True)}.", +) +@optgroup.option( + "--resubmit-from", + type=str, + default=None, + help=( + f"Colon-, comma-, or space-separated ordered list of hosts to try resubmitting from. " + f"The job is resubmitted from the first reachable host.\n" + f"Allowed values: {click.style('input', bold=True)} (the submission machine), " + f"{click.style('working', bold=True)} (the execution node), " + f"or a specific hostname (e.g., perian.metacentrum.cz).\n" + f"Defaults to {click.style(default_resubmit_from_hosts(), bold=True)}.\n" + f"Examples: 'input', 'input,working', 'input:st1:st2', 'working perian.metacentrum.cz'." + ), +) +@optgroup.group( + f"{click.style('Settings for loop jobs', fg='yellow')}", help=f"Only used when job-type is {click.style('loop', bold=True)}.", ) @optgroup.option( diff --git a/src/qq_lib/submit/factory.py b/src/qq_lib/submit/factory.py index 4b1ea84..57b32e1 100644 --- a/src/qq_lib/submit/factory.py +++ b/src/qq_lib/submit/factory.py @@ -11,6 +11,7 @@ from qq_lib.properties.job_type import JobType from qq_lib.properties.loop import LoopInfo from qq_lib.properties.resources import Resources +from qq_lib.properties.resubmit_host import ResubmitHost from qq_lib.properties.transfer_mode import TransferMode from .parser import Parser @@ -74,6 +75,7 @@ def make_submitter(self) -> Submitter: self._get_transfer_mode(), server, self._get_interpreter(), + self._get_resubmit_from(), ) def _get_batch_system(self) -> type[BatchInterface]: @@ -299,3 +301,22 @@ def _get_interpreter(self) -> str | None: return interpreter return None + + def _get_resubmit_from(self) -> list[ResubmitHost]: + """ + Determine the list of resubmission hosts to be used + to resubmit loop/continuous job. + + Priority: + 1. Resubmission hosts specified on the command line. + 2. Resubmission hosts specified inside the submitted script. + + The lists are NOT merged. + + Returns: + list[ResubmitHost]: List of resubmission hosts. + """ + return ( + ResubmitHost.multi_from_str(self._kwargs.get("resubmit_from") or "") + or self._parser.get_resubmit_from() + ) diff --git a/src/qq_lib/submit/parser.py b/src/qq_lib/submit/parser.py index 6c4effc..9b9e325 100644 --- a/src/qq_lib/submit/parser.py +++ b/src/qq_lib/submit/parser.py @@ -15,6 +15,7 @@ from qq_lib.properties.depend import Depend from qq_lib.properties.job_type import JobType from qq_lib.properties.resources import Resources +from qq_lib.properties.resubmit_host import ResubmitHost from qq_lib.properties.transfer_mode import TransferMode logger = get_logger(__name__) @@ -302,6 +303,18 @@ def get_interpreter(self) -> str | None: return None + def get_resubmit_from(self) -> list[ResubmitHost]: + """ + Return the list of resubmission hosts. + + Returns: + list[ResubmitHost]: List of job dependencies. + """ + if (raw := self._options.get("resubmit_from")) is not None: + return ResubmitHost.multi_from_str(str(raw)) + + return [] + @staticmethod def _strip_and_split(string: str) -> list[str]: """ diff --git a/src/qq_lib/submit/submitter.py b/src/qq_lib/submit/submitter.py index 286191f..153c4b6 100644 --- a/src/qq_lib/submit/submitter.py +++ b/src/qq_lib/submit/submitter.py @@ -25,6 +25,7 @@ from qq_lib.properties.job_type import JobType from qq_lib.properties.loop import LoopInfo from qq_lib.properties.resources import Resources +from qq_lib.properties.resubmit_host import ResubmitHost from qq_lib.properties.states import NaiveState from qq_lib.properties.transfer_mode import TransferMode @@ -60,6 +61,7 @@ def __init__( transfer_mode: list[TransferMode] | None = None, server: str | None = None, interpreter: str | None = None, + resubmit_from: list[ResubmitHost] | None = None, ): """ Initialize a Submitter instance. @@ -85,6 +87,8 @@ def __init__( If `None`, the default batch server, as configured by the batch system is used. intepreter (str | None): Optional executable name or absolute path of the interpreter to use to execute the script. If not specified, the config default is used. + resubmit_from (list[ResubmitHost] | None): List of hosts from which a loop/continuous job should be resubmitted. + If not specified, the config or batch system default is used. Raises: QQError: If the script does not exist or has an invalid shebang line. @@ -112,6 +116,7 @@ def __init__( CFG.transfer_files_options.default_transfer_mode ) self._interpreter = interpreter + self._resubmit_from = resubmit_from or [] # script must exist if not self._script.is_file(): @@ -151,6 +156,7 @@ def submit(self, remote: str | None = None) -> str: self._create_env_vars_dict(), self._account, self._server, + remote_host=remote, ) # create job qq info file @@ -179,6 +185,7 @@ def submit(self, remote: str | None = None) -> str: transfer_mode=self._transfer_mode, server=self._server, interpreter=self._interpreter, + resubmit_from=self._resubmit_from, ) ) @@ -315,6 +322,10 @@ def get_interpreter(self) -> str | None: """Get the interpreter to use for running the script.""" return self._interpreter + def get_resubmit_from(self) -> list[ResubmitHost] | None: + """Get the list of hosts to resubmit the job from.""" + return self._resubmit_from + def _create_env_vars_dict(self) -> dict[str, str]: """ Create a dictionary of environment variables provided to qq runtime. diff --git a/tests/test_batch_pbs_pbs.py b/tests/test_batch_pbs_pbs.py index e7c6b37..7541aff 100644 --- a/tests/test_batch_pbs_pbs.py +++ b/tests/test_batch_pbs_pbs.py @@ -66,7 +66,9 @@ def test_shared_guard_sets_env_var(): # patch is_shared to return True with patch.object(PBS, "is_shared", return_value=True): - PBS._shared_guard(Resources(work_dir="scratch_local"), env_vars, None, None) + PBS._shared_guard( + Path(), Resources(work_dir="scratch_local"), env_vars, None, None + ) assert env_vars[CFG.env_vars.shared_submit] == "true" # previous env vars not removed assert env_vars[CFG.env_vars.guard] == "true" @@ -77,7 +79,13 @@ def test_shared_guard_does_not_set_env_var(): # patch is_shared to return False with patch.object(PBS, "is_shared", return_value=False): - PBS._shared_guard(Resources(work_dir="scratch_local"), env_vars, None, None) + PBS._shared_guard( + Path("/path/to/local/dir"), + Resources(work_dir="scratch_local"), + env_vars, + None, + None, + ) assert CFG.env_vars.shared_submit not in env_vars # previous env vars not removed assert env_vars[CFG.env_vars.guard] == "true" @@ -89,7 +97,9 @@ def test_shared_guard_input_dir_does_not_raise(dir): # patch isShared to return True with patch.object(PBS, "is_shared", return_value=True): - PBS._shared_guard(Resources(work_dir=dir), env_vars, None, None) + PBS._shared_guard( + Path("/path/to/shared/dir"), Resources(work_dir=dir), env_vars, None, None + ) assert env_vars[CFG.env_vars.shared_submit] == "true" @@ -105,7 +115,7 @@ def test_shared_guard_input_dir_raises(dir): match="Job was requested to run directly in the submission directory", ), ): - PBS._shared_guard(Resources(work_dir=dir), env_vars, None, None) + PBS._shared_guard(Path(), Resources(work_dir=dir), env_vars, None, None) assert CFG.env_vars.shared_submit not in env_vars @@ -117,13 +127,21 @@ def test_shared_guard_raises_when_server_specified_and_not_shared(): match="which is potentially non-local", ), ): - PBS._shared_guard(Resources(work_dir="scratch_local"), {}, "server", None) + PBS._shared_guard( + Path(), Resources(work_dir="scratch_local"), {}, "server", None + ) def test_shared_guard_does_not_raise_when_server_specified_and_shared(): env_vars = {} with patch.object(PBS, "is_shared", return_value=True): - PBS._shared_guard(Resources(work_dir="scratch_local"), env_vars, "sokar", None) + PBS._shared_guard( + Path("/path/to/shared/dir"), + Resources(work_dir="scratch_local"), + env_vars, + "sokar", + None, + ) assert env_vars[CFG.env_vars.shared_submit] == "true" @@ -136,7 +154,11 @@ def test_shared_guard_raises_when_remote_specified_and_not_shared(): ), ): PBS._shared_guard( - Resources(work_dir="scratch_local"), {}, None, "random.node.org" + Path("/path/to/local/dir"), + Resources(work_dir="scratch_local"), + {}, + None, + "random.node.org", ) @@ -144,7 +166,11 @@ def test_shared_guard_does_not_raise_when_remote_specified_and_shared(): env_vars = {} with patch.object(PBS, "is_shared", return_value=True): PBS._shared_guard( - Resources(work_dir="scratch_local"), env_vars, None, "random.node.org" + Path(), + Resources(work_dir="scratch_local"), + env_vars, + None, + "random.node.org", ) assert env_vars[CFG.env_vars.shared_submit] == "true" diff --git a/tests/test_batch_slurm_slurm.py b/tests/test_batch_slurm_slurm.py index 53b0b70..caad18e 100644 --- a/tests/test_batch_slurm_slurm.py +++ b/tests/test_batch_slurm_slurm.py @@ -737,7 +737,7 @@ def test_slurm_job_submit_success(mock_guard, mock_translate, mock_run): result = Slurm.job_submit(res, "qgpu", script, "job1", [], {}, "acc") - mock_guard.assert_called_once_with(res, {}, None, None) + mock_guard.assert_called_once_with(script.parent, res, {}, None, None) mock_translate.assert_called_once() mock_run.assert_called_once() assert result == "56789" @@ -754,7 +754,7 @@ def test_slurm_job_submit_raises_on_error(mock_guard, mock_translate, mock_run): with pytest.raises(QQError, match="Failed to submit script"): Slurm.job_submit(res, "qgpu", script, "fail_job", [], {}, None) - mock_guard.assert_called_once_with(res, {}, None, None) + mock_guard.assert_called_once_with(script.parent, res, {}, None, None) mock_translate.assert_called_once() mock_run.assert_called_once() diff --git a/tests/test_properties_info.py b/tests/test_properties_info.py index ad13aba..3589305 100644 --- a/tests/test_properties_info.py +++ b/tests/test_properties_info.py @@ -15,8 +15,8 @@ from qq_lib.core.error import QQError from qq_lib.properties.info import CFG, Info from qq_lib.properties.job_type import JobType -from qq_lib.properties.loop import LoopInfo from qq_lib.properties.resources import Resources +from qq_lib.properties.resubmit_host import ExplicitHost, InputHost from qq_lib.properties.states import NaiveState @@ -53,6 +53,7 @@ def sample_info(sample_resources): excluded_files=[Path("ignore.txt")], work_dir=Path("/scratch/job_12345.fake.server.com"), account="fake-account", + resubmit_from=[InputHost(), ExplicitHost("node01.fake.server.com")], ) @@ -61,6 +62,7 @@ def test_to_dict_skips_none(sample_info): assert "start_time" not in result assert "completion_time" not in result assert "job_exit_code" not in result + assert "depend" not in result assert result["job_id"] == "12345.fake.server.com" assert result["resources"]["ncpus"] == 8 @@ -89,6 +91,7 @@ def test_to_dict_contains_all_non_none_fields(sample_info): "excluded_files", "account", "transfer_mode", + "resubmit_from", } assert expected_fields.issubset(result.keys()) @@ -108,6 +111,7 @@ def test_to_yaml_contains_fields(sample_info): assert data["resources"]["ncpus"] == 8 assert data["account"] == "fake-account" assert data["transfer_mode"] == ["success"] + assert data["resubmit_from"] == ["input", "node01.fake.server.com"] def test_to_yaml_with_slurm_contains_fields(sample_info): @@ -121,6 +125,7 @@ def test_to_yaml_with_slurm_contains_fields(sample_info): assert data["resources"]["ncpus"] == 8 assert data["account"] == "fake-account" assert data["transfer_mode"] == ["success"] + assert data["resubmit_from"] == ["input", "node01.fake.server.com"] def test_to_yaml_skips_none_fields(sample_info): @@ -130,6 +135,7 @@ def test_to_yaml_skips_none_fields(sample_info): assert "start_time" not in data assert "completion_time" not in data assert "job_exit_code" not in data + assert "depend" not in data def test_export_to_file_creates_file(sample_info, tmp_path): @@ -202,6 +208,7 @@ def test_from_dict_roundtrip(sample_info): "stdout_file", "stderr_file", "transfer_mode", + "resubmit_from", ]: assert getattr(reconstructed, field_name) == getattr(sample_info, field_name) assert type(getattr(reconstructed, field_name)) is type( @@ -301,122 +308,3 @@ def test_from_file_missing_required_field(tmp_path): with pytest.raises(QQError, match=r"Invalid qq info file"): Info.from_file(file) - - -def test_get_command_line_for_resubmit_basic(sample_info): - sample_info.resources = Resources() - sample_info.account = None - sample_info.excluded_files = [] - - assert sample_info.get_command_line_for_resubmit() == [ - "script.sh", - "--queue", - "default", - "--job-type", - "standard", - "--batch-system", - "PBS", - "--depend", - "afterok=12345.fake.server.com", - "--transfer-mode", - "success", - ] - - -def test_get_command_line_for_resubmit_basic_with_server(sample_info): - sample_info.resources = Resources() - sample_info.account = None - sample_info.excluded_files = [] - sample_info.server = "fake.server.com" - - assert sample_info.get_command_line_for_resubmit() == [ - "script.sh", - "--queue", - "default", - "--job-type", - "standard", - "--batch-system", - "PBS", - "--depend", - "afterok=12345.fake.server.com", - "--server", - "fake.server.com", - "--transfer-mode", - "success", - ] - - -def test_get_command_line_for_continuous(sample_info): - sample_info.job_type = JobType.CONTINUOUS - sample_info.excluded_files = [Path("exclude.txt"), Path("inner/exclude2.txt")] - sample_info.included_files = [Path("include.txt"), Path("inner/include2.txt")] - - assert sample_info.get_command_line_for_resubmit() == [ - "script.sh", - "--queue", - "default", - "--job-type", - "continuous", - "--batch-system", - "PBS", - "--depend", - "afterok=12345.fake.server.com", - "--ncpus", - "8", - "--work-dir", - "scratch_local", - "--account", - "fake-account", - "--exclude", - "exclude.txt,inner/exclude2.txt", - "--include", - "include.txt,inner/include2.txt", - "--transfer-mode", - "success", - ] - - -def test_get_command_line_full(sample_info): - sample_info.job_type = JobType.LOOP - sample_info.excluded_files = [Path("exclude.txt"), Path("inner/exclude2.txt")] - sample_info.included_files = [Path("include.txt"), Path("inner/include2.txt")] - sample_info.loop_info = LoopInfo( - start=3, end=10, archive=Path("inner/inner2/archive"), archive_format="job%3d" - ) - sample_info.server = "fake.server.com" - - assert sample_info.get_command_line_for_resubmit() == [ - "script.sh", - "--queue", - "default", - "--job-type", - "loop", - "--batch-system", - "PBS", - "--depend", - "afterok=12345.fake.server.com", - "--ncpus", - "8", - "--work-dir", - "scratch_local", - "--server", - "fake.server.com", - "--account", - "fake-account", - "--exclude", - "exclude.txt,inner/exclude2.txt", - "--include", - "include.txt,inner/include2.txt", - "--loop-start", - "3", - "--loop-end", - "10", - "--archive", - "archive", - "--archive-format", - "job%3d", - "--archive-mode", - "success", - "--transfer-mode", - "success", - ] diff --git a/tests/test_properties_loop.py b/tests/test_properties_loop.py index ee58ca9..a62d51d 100644 --- a/tests/test_properties_loop.py +++ b/tests/test_properties_loop.py @@ -230,50 +230,6 @@ def test_get_cycle_non_numeric_files_are_ignored_but_numeric_stems_count(temp_di assert loop_info.determine_cycle_from_archive() == 10 -def test_to_command_line_basic(): - info = LoopInfo( - start=1, - end=10, - archive=Path("/tmp/archive"), - archive_format="job%04d", - ) - - assert info.to_command_line() == [ - "--loop-start", - "1", - "--loop-end", - "10", - "--archive", - "archive", - "--archive-format", - "job%04d", - "--archive-mode", - "success", - ] - - -def test_to_command_line_archive_name_only(): - info = LoopInfo( - start=0, - end=5, - archive=Path("/very/long/path/to/myarchive"), - archive_format="md%03d", - ) - - assert info.to_command_line() == [ - "--loop-start", - "0", - "--loop-end", - "5", - "--archive", - "myarchive", - "--archive-format", - "md%03d", - "--archive-mode", - "success", - ] - - @pytest.mark.parametrize( "start, end, current, archive_format", [ diff --git a/tests/test_properties_resources.py b/tests/test_properties_resources.py index 8b5b783..9faa86d 100644 --- a/tests/test_properties_resources.py +++ b/tests/test_properties_resources.py @@ -491,68 +491,3 @@ def test_props_to_value_non_boolean_strings(): "c": "trueish", } assert res._props_to_value() == "a=TRUE,b=False,c=trueish" - - -def test_to_command_line_int_values(): - res = Resources(nnodes=3, ncpus=12) - - assert res.to_command_line() == ["--nnodes", "3", "--ncpus", "12"] - - -def test_to_command_line_size_values(): - res = Resources(mem="4gb", work_size="10mb") - - assert res.to_command_line() == [ - "--mem", - "4194304kb", - "--work-size", - "10240kb", - ] - - -def test_to_command_line_string_values(): - res = Resources(walltime="02:00:00", work_dir="scratch_local") - - assert res.to_command_line() == [ - "--walltime", - "02:00:00", - "--work-dir", - "scratch_local", - ] - - -def test_to_command_line_props_value(): - res = Resources(props="debug,^gpu,type=A") - - assert res.to_command_line() == ["--props", "debug,^gpu,type=A"] - - -def test_to_command_line_mixed_value_types(): - res = Resources(nnodes=2, mem="1gb", work_dir="scratch", props="debug") - assert res.to_command_line() == [ - "--nnodes", - "2", - "--mem", - "1048576kb", - "--work-dir", - "scratch", - "--props", - "debug", - ] - - -def test_to_command_line_mixed_value_types_no_props(): - res = Resources(nnodes=2, mem="1gb", work_dir="scratch") - assert res.to_command_line() == [ - "--nnodes", - "2", - "--mem", - "1048576kb", - "--work-dir", - "scratch", - ] - - -def test_to_command_line_empty(): - res = Resources() - assert res.to_command_line() == [] diff --git a/tests/test_run_resubmitter.py b/tests/test_run_resubmitter.py index cfb6cd9..57f711a 100644 --- a/tests/test_run_resubmitter.py +++ b/tests/test_run_resubmitter.py @@ -58,6 +58,7 @@ def test_resubmitter_build_submitter_creates_submitter_with_correct_params(): transfer_mode=informer.info.transfer_mode, server=informer.info.server, interpreter=informer.info.interpreter, + resubmit_from=informer.info.resubmit_from, ) diff --git a/tests/test_submit_factory.py b/tests/test_submit_factory.py index 40871f0..4db3700 100644 --- a/tests/test_submit_factory.py +++ b/tests/test_submit_factory.py @@ -12,6 +12,7 @@ from qq_lib.properties.job_type import JobType from qq_lib.properties.loop import LoopInfo from qq_lib.properties.resources import Resources +from qq_lib.properties.resubmit_host import InputHost, ResubmitHost, WorkHost from qq_lib.properties.size import Size from qq_lib.properties.transfer_mode import ( Always, @@ -571,6 +572,39 @@ def test_submitter_factory_get_interpreter_returns_none_if_no_cli_no_parser(): assert result is None +def test_submitter_factory_get_resubmit_from_from_command_line(): + mock_parser = MagicMock() + parser_resubmit_from = [MagicMock(), MagicMock()] + mock_parser.get_resubmit_from.return_value = parser_resubmit_from + + factory = SubmitterFactory.__new__(SubmitterFactory) + factory._parser = mock_parser + factory._kwargs = {"resubmit_from": "input,node01"} + + cli_resubmit_from = [MagicMock(), MagicMock()] + + with patch.object( + ResubmitHost, "multi_from_str", return_value=cli_resubmit_from + ) as mock_multi: + result = factory._get_resubmit_from() + + mock_multi.assert_called_once_with("input,node01") + assert result == cli_resubmit_from + + +def test_submitter_factory_get_resubmit_from_from_parser(): + mock_parser = MagicMock() + parser_resubmit_from = [MagicMock(), MagicMock()] + mock_parser.get_resubmit_from.return_value = parser_resubmit_from + + factory = SubmitterFactory.__new__(SubmitterFactory) + factory._parser = mock_parser + factory._kwargs = {} + + result = factory._get_resubmit_from() + assert result == parser_resubmit_from + + @pytest.mark.parametrize("server", [None, "fake.server.org"]) def test_submitter_factory_make_submitter_standard_job(server): mock_parser = MagicMock() @@ -583,6 +617,7 @@ def test_submitter_factory_make_submitter_standard_job(server): account = "fake-account" transfer = [Always()] interpreter = None + resubmit_from = [WorkHost(), InputHost()] factory = SubmitterFactory.__new__(SubmitterFactory) factory._parser = mock_parser @@ -610,6 +645,9 @@ def test_submitter_factory_make_submitter_standard_job(server): factory, "_get_transfer_mode", return_value=transfer ) as mock_get_transfer, patch.object(factory, "_get_server", return_value=server) as mock_get_server, + patch.object( + factory, "_get_resubmit_from", return_value=resubmit_from + ) as mock_resubmit_from, patch("qq_lib.submit.factory.Submitter") as mock_submitter_class, ): mock_submit_instance = MagicMock() @@ -629,6 +667,7 @@ def test_submitter_factory_make_submitter_standard_job(server): 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_submitter_class.assert_called_once_with( BatchSystem, @@ -644,6 +683,7 @@ def test_submitter_factory_make_submitter_standard_job(server): transfer, server, interpreter, + resubmit_from, ) assert result == mock_submit_instance @@ -660,6 +700,7 @@ def test_submitter_factory_make_submitter_loop_job(server): account = None transfer = [Always()] interpreter = "python3" + resubmit_from = [WorkHost(), InputHost()] factory = SubmitterFactory.__new__(SubmitterFactory) factory._parser = mock_parser @@ -689,6 +730,9 @@ def test_submitter_factory_make_submitter_loop_job(server): 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, ): @@ -709,6 +753,7 @@ def test_submitter_factory_make_submitter_loop_job(server): 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_submitter_class.assert_called_once_with( BatchSystem, @@ -724,5 +769,6 @@ def test_submitter_factory_make_submitter_loop_job(server): transfer, server, interpreter, + resubmit_from, ) assert result == mock_submit_instance diff --git a/tests/test_submit_parser.py b/tests/test_submit_parser.py index 057aa3f..27b2cb7 100644 --- a/tests/test_submit_parser.py +++ b/tests/test_submit_parser.py @@ -15,6 +15,7 @@ from qq_lib.properties.depend import Depend from qq_lib.properties.job_type import JobType from qq_lib.properties.resources import Resources +from qq_lib.properties.resubmit_host import ResubmitHost from qq_lib.properties.size import Size from qq_lib.properties.transfer_mode import TransferMode from qq_lib.submit.cli import submit @@ -423,6 +424,29 @@ def test_parser_get_interpreter_value(): assert result == "/usr/bin/python" +def test_parser_get_resubmit_from_empty_list(): + parser = Parser.__new__(Parser) + parser._options = {} + + result = parser.get_resubmit_from() + assert result == [] + + +def test_parser_get_resubmit_from_calls_multi_from_str(): + parser = Parser.__new__(Parser) + parser._options = {"resubmit_from": "input,working,node01"} + + mock_resubmit_hosts = [MagicMock(), MagicMock(), MagicMock()] + + with patch.object( + ResubmitHost, "multi_from_str", return_value=mock_resubmit_hosts + ) as mock_multi: + result = parser.get_resubmit_from() + + mock_multi.assert_called_once_with("input,working,node01") + assert result == mock_resubmit_hosts + + @pytest.fixture def temp_script_file(): with tempfile.NamedTemporaryFile(mode="w+", delete=False) as tmp_file: diff --git a/tests/test_submit_submitter.py b/tests/test_submit_submitter.py index d82626a..69e2b83 100644 --- a/tests/test_submit_submitter.py +++ b/tests/test_submit_submitter.py @@ -17,6 +17,7 @@ from qq_lib.properties.job_type import JobType from qq_lib.properties.loop import LoopInfo from qq_lib.properties.resources import Resources +from qq_lib.properties.resubmit_host import ExplicitHost, WorkHost from qq_lib.properties.states import NaiveState from qq_lib.properties.transfer_mode import Always, Success from qq_lib.submit.submitter import CFG, Submitter @@ -57,6 +58,7 @@ def test_submitter_init_sets_all_attributes_correctly(tmp_path): assert submitter._depend == [] assert isinstance(submitter._transfer_mode[0], Always) assert submitter._server is None + assert submitter._resubmit_from == [] def test_submitter_init_raises_error_if_script_does_not_exist(tmp_path): @@ -118,6 +120,7 @@ def test_submitter_init_sets_all_optional_arguments_correctly(tmp_path): exclude=exclude_files, depend=depend_jobs, server="fake.server.com", + resubmit_from=[WorkHost(), ExplicitHost("node01")], ) assert submitter._batch_system == PBS @@ -134,6 +137,7 @@ def test_submitter_init_sets_all_optional_arguments_correctly(tmp_path): assert submitter._exclude == exclude_files assert submitter._depend == depend_jobs assert submitter._server == "fake.server.com" + assert submitter._resubmit_from == [WorkHost(), ExplicitHost("node01")] def test_submitter_construct_job_name_returns_script_name_for_standard_job( @@ -594,6 +598,7 @@ def test_submitter_submit_calls_all_steps_and_returns_job_id(tmp_path): submitter._info_file = tmp_path / f"{submitter._job_name}.qqinfo" submitter._server = None submitter._interpreter = "python3" + submitter._resubmit_from = [] env_vars = {CFG.env_vars.guard: "true"} with ( @@ -621,6 +626,7 @@ def test_submitter_submit_calls_all_steps_and_returns_job_id(tmp_path): env_vars, submitter._account, submitter._server, + remote_host=None, ) mock_informer_class.assert_called_once() mock_informer_instance.to_file.assert_called_once_with(submitter._info_file) @@ -646,6 +652,7 @@ def test_submitter_submit(tmp_path): submitter._server = "fake.server.com" submitter._info_file = tmp_path / f"{submitter._job_name}.qqinfo" submitter._interpreter = None + submitter._resubmit_from = [WorkHost()] env_vars = {CFG.env_vars.guard: "true"} with ( @@ -677,6 +684,7 @@ def test_submitter_submit(tmp_path): env_vars, submitter._account, submitter._server, + remote_host=None, ) mock_informer_class.assert_called_once() mock_informer_instance.to_file.assert_called_once_with(submitter._info_file) @@ -712,3 +720,4 @@ def test_submitter_submit(tmp_path): assert info_arg.transfer_mode == [Always()] assert info_arg.server == submitter._server assert info_arg.interpreter is None + assert info_arg.resubmit_from == [WorkHost()] From 914c78e40ea919522c78bf5aa53c86042645559c Mon Sep 17 00:00:00 2001 From: Ladme Date: Thu, 7 May 2026 16:20:33 +0200 Subject: [PATCH 20/43] Fixed a bug where resubmission from local storage was never possible --- src/qq_lib/batch/pbs/pbs.py | 6 ++++-- tests/test_batch_pbs_pbs.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/qq_lib/batch/pbs/pbs.py b/src/qq_lib/batch/pbs/pbs.py index b4651c5..36179f6 100644 --- a/src/qq_lib/batch/pbs/pbs.py +++ b/src/qq_lib/batch/pbs/pbs.py @@ -548,8 +548,10 @@ def _shared_guard( raise QQError( f"Job was requested to be submitted to server '{server}' which is potentially non-local, but the submission is done from a local filesystem." ) - elif remote_host is not None: - # if we are submitting from a different host + elif ( + remote_host is not None and socket.getfqdn(remote_host) != socket.getfqdn() + ): + # if we are submitting from a different host than the current one raise QQError( f"Job was requested to be submitted from host '{remote_host}', but the submission is done from a local filesystem." ) diff --git a/tests/test_batch_pbs_pbs.py b/tests/test_batch_pbs_pbs.py index 7541aff..ec78ce1 100644 --- a/tests/test_batch_pbs_pbs.py +++ b/tests/test_batch_pbs_pbs.py @@ -175,6 +175,20 @@ def test_shared_guard_does_not_raise_when_remote_specified_and_shared(): assert env_vars[CFG.env_vars.shared_submit] == "true" +def test_shared_guard_does_not_raise_when_remote_is_current_host_and_not_shared(): + with ( + patch.object(PBS, "is_shared", return_value=False), + patch("qq_lib.batch.pbs.pbs.socket.getfqdn", return_value="node01.cluster.org"), + ): + PBS._shared_guard( + Path("/path/to/local/dir"), + Resources(work_dir="scratch_local"), + {}, + None, + "node01.cluster.org", + ) + + def test_sync_with_exclusions_shared_storage_sets_local(monkeypatch): src_dir = Path("/src") dest_dir = Path("/dest") From 8cc81484b000200b59cad5b30f2bab6d0bc60426 Mon Sep 17 00:00:00 2001 From: Ladme Date: Thu, 7 May 2026 16:24:34 +0200 Subject: [PATCH 21/43] Updated changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0207732..bc4acd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ ## Version 0.11 - Updated the installation scripts to more clearly report issues that occured during the install. +### Resubmitting with fallback hosts +- Resubmission of loop and continuous jobs now supports multiple fallback hosts. Previously, jobs were resubmitted from a single machine (the input machine or the working node, depending on the batch system). A list of hosts can now be specified via `--resubmit-from` or in the config file; they are tried in order until one succeeds, making resubmission more resilient to individual machine failures. + ### Internal changes - qq now uses Python 3.13 for better generics support. - Fixed type errors in qq scripts. From c35afe057da711d1a785a29ce3a95872256469db Mon Sep 17 00:00:00 2001 From: Ladme Date: Thu, 7 May 2026 16:34:55 +0200 Subject: [PATCH 22/43] Handling config file reading failures --- CHANGELOG.md | 1 + src/qq_lib/core/config.py | 14 +++++++------- tests/test_core_config.py | 8 +++----- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc4acd9..f70e031 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Jobs can be now submitted from a remote machine. - Submitting jobs using `Submitter.submit` is now thread-safe. - Handling exceptions that occur when reading a configuration file. +- When reading configuration file fails, an exception is no longer raised, instead an error is reported and a default configuration is used. *** diff --git a/src/qq_lib/core/config.py b/src/qq_lib/core/config.py index d094a38..9738343 100644 --- a/src/qq_lib/core/config.py +++ b/src/qq_lib/core/config.py @@ -13,7 +13,6 @@ """ import os -import sys import tomllib from dataclasses import dataclass, field, fields, is_dataclass from pathlib import Path @@ -518,7 +517,12 @@ def load(cls, config_path: Path | None = None) -> Self: config_data = tomllib.load(f) return _dict_to_dataclass(cls, config_data) except Exception as e: - raise ValueError(f"Could not read qq config '{config_path}': {e}.") + print( + f"[ FATAL CONFIGURATION ERROR ] Could not read qq config '{config_path}': {e}." + ) + print( + "[ FATAL CONFIGURATION ERROR ] Falling back to default configuration.\n\n" + ) # no config found - use defaults return cls() @@ -571,8 +575,4 @@ def _dict_to_dataclass(cls, data: dict[str, Any]): # Global configuration for qq. -try: - CFG = Config.load() -except Exception as e: - print(f"[ FATAL CONFIGURATION ERROR ] Could not read qq config file: {e}") - sys.exit(91) +CFG = Config.load() diff --git a/tests/test_core_config.py b/tests/test_core_config.py index d8a9790..f364084 100644 --- a/tests/test_core_config.py +++ b/tests/test_core_config.py @@ -4,8 +4,6 @@ from dataclasses import dataclass, field -import pytest - from qq_lib.core.config import Config, _dict_to_dataclass @@ -303,12 +301,12 @@ def test_load_large_config_override(tmp_path): assert config.exit_codes.unexpected_error == 150 -def test_load_with_invalid_toml_raises_error(tmp_path): +def test_load_with_invalid_toml_falls_back_to_default(tmp_path): config_file = tmp_path / "config.toml" config_file.write_text(""" [runner retry_tries = 5 """) # missing closing bracket - with pytest.raises(ValueError, match="Could not read qq config"): - Config.load(config_file) + config = Config.load(config_file) + assert config == Config() From 1923daa5640edecb247ad1b063fab308e3d1b483 Mon Sep 17 00:00:00 2001 From: Ladme Date: Thu, 7 May 2026 16:38:38 +0200 Subject: [PATCH 23/43] Fixed CI error --- src/qq_lib/core/common.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/qq_lib/core/common.py b/src/qq_lib/core/common.py index ba856aa..4593fc5 100644 --- a/src/qq_lib/core/common.py +++ b/src/qq_lib/core/common.py @@ -758,7 +758,11 @@ def default_resubmit_from_hosts() -> str: """ from qq_lib.batch.interface import BatchInterface - return CFG.resubmitter.default_resubmit_hosts or ",".join( - x.to_str() - for x in BatchInterface.from_env_var_or_guess().get_default_resubmit_hosts() - ) + try: + return CFG.resubmitter.default_resubmit_hosts or ",".join( + x.to_str() + for x in BatchInterface.from_env_var_or_guess().get_default_resubmit_hosts() + ) + # if no batch system is available + except QQError: + return "??? (no batch system detected)" From 0e44586b0eaaac0caf4c39af93f293a9414022d7 Mon Sep 17 00:00:00 2001 From: Ladme Date: Thu, 7 May 2026 20:43:19 +0200 Subject: [PATCH 24/43] Making CFG into a frozen dataclass --- CHANGELOG.md | 1 + src/qq_lib/core/config.py | 50 +++++++++++++++--------------- tests/test_batch_interface.py | 6 ++-- tests/test_batch_pbs_pbs.py | 3 +- tests/test_batch_slurm_job.py | 58 +++++++++++++++++------------------ tests/test_run_runner.py | 8 +++-- 6 files changed, 66 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f70e031..48d3150 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Submitting jobs using `Submitter.submit` is now thread-safe. - Handling exceptions that occur when reading a configuration file. - When reading configuration file fails, an exception is no longer raised, instead an error is reported and a default configuration is used. +- `CFG` is now a frozen dataclass. *** diff --git a/src/qq_lib/core/config.py b/src/qq_lib/core/config.py index 9738343..d716738 100644 --- a/src/qq_lib/core/config.py +++ b/src/qq_lib/core/config.py @@ -19,7 +19,7 @@ from typing import Any, Self -@dataclass +@dataclass(frozen=True) class FileSuffixes: """File suffixes used by qq.""" @@ -38,7 +38,7 @@ def all_suffixes(self) -> list[str]: return [self.qq_info, self.qq_out, self.stdout, self.stderr] -@dataclass +@dataclass(frozen=True) class EnvironmentVariables: """Environment variable names used by qq.""" @@ -82,7 +82,7 @@ class EnvironmentVariables: walltime: str = "QQ_WALLTIME" -@dataclass +@dataclass(frozen=True) class TimeoutSettings: """Timeout settings in seconds.""" @@ -92,7 +92,7 @@ class TimeoutSettings: rsync: int = 600 -@dataclass +@dataclass(frozen=True) class RunnerSettings: """Settings for Runner operations.""" @@ -108,7 +108,7 @@ class RunnerSettings: default_interpreter: str = "bash" -@dataclass +@dataclass(frozen=True) class ResubmitterSettings: """Settings for Resubmitter operations.""" @@ -121,7 +121,7 @@ class ResubmitterSettings: default_resubmit_hosts: str = "" -@dataclass +@dataclass(frozen=True) class ArchiverSettings: """Settings for Archiver operations.""" @@ -131,7 +131,7 @@ class ArchiverSettings: retry_wait: int = 300 -@dataclass +@dataclass(frozen=True) class GoerSettings: """Settings for Goer operations.""" @@ -140,7 +140,7 @@ class GoerSettings: wait_time: int = 5 -@dataclass +@dataclass(frozen=True) class LoopJobSettings: """Settings for qq loop jobs.""" @@ -148,7 +148,7 @@ class LoopJobSettings: pattern: str = "+%04d" -@dataclass +@dataclass(frozen=True) class JobStatusPanelSettings: """Settings for creating a job status panel.""" @@ -162,7 +162,7 @@ class JobStatusPanelSettings: title_style: str = "white bold" -@dataclass +@dataclass(frozen=True) class FullInfoPanelSettings: """Settings for creating a full info panel.""" @@ -178,7 +178,7 @@ class FullInfoPanelSettings: rule_style: str = "white" -@dataclass +@dataclass(frozen=True) class PresenterSettings: """Settings for Presenter.""" @@ -200,7 +200,7 @@ class PresenterSettings: notes_style: str = "grey50" -@dataclass +@dataclass(frozen=True) class JobsPresenterSettings: """Settings for JobsPresenter.""" @@ -238,7 +238,7 @@ class JobsPresenterSettings: sum_jobs_code: str = "Σ" -@dataclass +@dataclass(frozen=True) class QueuesPresenterSettings: """Settings for QueuesPresenter.""" @@ -278,7 +278,7 @@ class QueuesPresenterSettings: sum_jobs_code: str = "Σ" -@dataclass +@dataclass(frozen=True) class NodesPresenterSettings: """Settings for NodesPresenter.""" @@ -320,7 +320,7 @@ class NodesPresenterSettings: unavailable_node_style = "bright_red" -@dataclass +@dataclass(frozen=True) class DateFormats: """Date and time format strings.""" @@ -332,7 +332,7 @@ class DateFormats: slurm: str = "%Y-%m-%dT%H:%M:%S" -@dataclass +@dataclass(frozen=True) class ExitCodes: """Exit codes used for various errors.""" @@ -350,7 +350,7 @@ class ExitCodes: unexpected_error: int = 99 -@dataclass +@dataclass(frozen=True) class StateColors: """Color scheme for RealState display.""" @@ -384,7 +384,7 @@ class StateColors: other: str = "grey70" -@dataclass +@dataclass(frozen=True) class SizeOptions: """Options associated with the Size dataclass.""" @@ -392,7 +392,7 @@ class SizeOptions: max_rounding_error: float = 0.1 -@dataclass +@dataclass(frozen=True) class PBSOptions: """Options associated with PBS.""" @@ -400,7 +400,7 @@ class PBSOptions: scratch_dir_inner: str = "main" -@dataclass +@dataclass(frozen=True) class SlurmOptions: """Options associated with Slurm.""" @@ -408,7 +408,7 @@ class SlurmOptions: jobs_scontrol_nthreads: int = 8 -@dataclass +@dataclass(frozen=True) class SlurmIT4IOptions: """Options associated with Slurm on IT4I clusters.""" @@ -416,7 +416,7 @@ class SlurmIT4IOptions: scratch_dir_attempts: int = 3 -@dataclass +@dataclass(frozen=True) class SlurmLumiOptions: """Options associated with Slurm on LUMI.""" @@ -424,7 +424,7 @@ class SlurmLumiOptions: scratch_dir_attempts: int = 3 -@dataclass +@dataclass(frozen=True) class TransferFilesOptions: """Options associated with transferring and archiving files.""" @@ -435,7 +435,7 @@ class TransferFilesOptions: default_transfer_mode: str = "success" -@dataclass +@dataclass(frozen=True) class BatchServersOptions: """Options associated with selecting and specifying batch servers.""" @@ -459,7 +459,7 @@ class BatchServersOptions: ) -@dataclass +@dataclass(frozen=True) class Config: """Main configuration for qq.""" diff --git a/tests/test_batch_interface.py b/tests/test_batch_interface.py index aaa33a8..8c4feae 100644 --- a/tests/test_batch_interface.py +++ b/tests/test_batch_interface.py @@ -276,8 +276,9 @@ def test_sync_with_exclusions_rsync_timeout(tmp_path): with ( pytest.raises(QQError, match="Could not rsync files"), - patch("qq_lib.batch.interface.interface.CFG.timeouts.rsync", 0), + patch("qq_lib.batch.interface.interface.CFG") as cfg_mock, ): + cfg_mock.timeouts.rsync = 0 BatchInterface.sync_with_exclusions(src, dest, None, None) @@ -450,8 +451,9 @@ def test_sync_selected_rsync_timeout(tmp_path): with ( pytest.raises(QQError, match="Could not rsync files"), - patch("qq_lib.batch.interface.interface.CFG.timeouts.rsync", 0), + patch("qq_lib.batch.interface.interface.CFG") as cfg_mock, ): + cfg_mock.timeouts.rsync = 0 BatchInterface.sync_selected( src, dest, None, None, include_files=[src / "file.txt"] ) diff --git a/tests/test_batch_pbs_pbs.py b/tests/test_batch_pbs_pbs.py index ec78ce1..a1a9e32 100644 --- a/tests/test_batch_pbs_pbs.py +++ b/tests/test_batch_pbs_pbs.py @@ -1831,7 +1831,8 @@ def test_modify_ams_env_vars_raises_for_unknown_server_with_ams_vars(): ], ) def test_translate_output_server_no_server(input_dir, job_name, expected_path): - with patch("qq_lib.batch.pbs.pbs.CFG.suffixes.qq_out", ".qqout"): + with patch("qq_lib.batch.pbs.pbs.CFG") as cfg_mock: + cfg_mock.suffixes.qq_out = ".qqout" result = PBS._translate_output_server(input_dir, job_name, None) assert result == expected_path diff --git a/tests/test_batch_slurm_job.py b/tests/test_batch_slurm_job.py index 4de6d9b..40952ab 100644 --- a/tests/test_batch_slurm_job.py +++ b/tests/test_batch_slurm_job.py @@ -637,44 +637,42 @@ def test_slurm_job_get_input_dir_returns_none(): assert job.get_input_dir() is None -def test_slurm_job_get_info_file_returns_path_when_file_exists(monkeypatch): +def test_slurm_job_get_info_file_returns_path_when_file_exists(): job = SlurmJob.__new__(SlurmJob) fake_path = Path("/tmp/testfile.qqinfo") - monkeypatch.setattr(job, "get_input_dir", lambda: Path("/tmp")) - monkeypatch.setattr(job, "get_name", lambda: "testfile") - monkeypatch.setattr( - "qq_lib.batch.slurm.job.CFG.suffixes", MagicMock(qq_info=".qqinfo") - ) - monkeypatch.setattr(Path, "is_file", lambda _: True) - result = job.get_info_file() - assert result == fake_path + with ( + patch.object(job, "get_input_dir", return_value=Path("/tmp")), + patch.object(job, "get_name", return_value="testfile"), + patch("qq_lib.batch.slurm.job.CFG") as cfg_mock, + patch.object(Path, "is_file", return_value=True), + ): + cfg_mock.suffixes = MagicMock(qq_info=".qqinfo") + result = job.get_info_file() + assert result == fake_path -def test_slurm_job_get_info_file_returns_none_when_file_missing(monkeypatch): +def test_slurm_job_get_info_file_returns_none_when_file_missing(): job = SlurmJob.__new__(SlurmJob) - monkeypatch.setattr(job, "get_input_dir", lambda: Path("/tmp")) - monkeypatch.setattr(job, "get_name", lambda: "missingfile") - monkeypatch.setattr( - "qq_lib.batch.slurm.job.CFG.suffixes", MagicMock(qq_info=".qqinfo") - ) - monkeypatch.setattr(Path, "is_file", lambda _: False) - assert job.get_info_file() is None + with ( + patch.object(job, "get_input_dir", return_value=Path("/tmp")), + patch.object(job, "get_name", return_value="missingfile"), + patch("qq_lib.batch.slurm.job.CFG") as cfg_mock, + patch.object(Path, "is_file", return_value=False), + ): + cfg_mock.suffixes = MagicMock(qq_info=".qqinfo") + assert job.get_info_file() is None -def test_slurm_job_get_info_file_returns_none_when_permission_error(monkeypatch): +def test_slurm_job_get_info_file_returns_none_when_permission_error(): job = SlurmJob.__new__(SlurmJob) - monkeypatch.setattr(job, "get_input_dir", lambda: Path("/tmp")) - monkeypatch.setattr(job, "get_name", lambda: "noaccessfile") - monkeypatch.setattr( - "qq_lib.batch.slurm.job.CFG.suffixes", MagicMock(qq_info=".qqinfo") - ) - - def raise_permission_error(self): - _ = self - raise PermissionError - - monkeypatch.setattr(Path, "is_file", raise_permission_error) - assert job.get_info_file() is None + with ( + patch.object(job, "get_input_dir", return_value=Path("/tmp")), + patch.object(job, "get_name", return_value="noaccessfile"), + patch("qq_lib.batch.slurm.job.CFG") as cfg_mock, + patch.object(Path, "is_file", side_effect=PermissionError), + ): + cfg_mock.suffixes = MagicMock(qq_info=".qqinfo") + assert job.get_info_file() is None def test_slurm_job_to_yaml_returns_valid_yaml_string(): diff --git a/tests/test_run_runner.py b/tests/test_run_runner.py index e6fd282..9451ab0 100644 --- a/tests/test_run_runner.py +++ b/tests/test_run_runner.py @@ -658,9 +658,11 @@ def test_runner_update_info_running_raises_qqerror_on_failure(): with ( patch("qq_lib.run.runner.socket.getfqdn", return_value="localhost"), - patch("qq_lib.run.runner.CFG.runner.retry_wait", return_value=0.1), + patch("qq_lib.run.runner.CFG") as cfg_mock, pytest.raises(QQError, match="Could not update qqinfo file"), ): + cfg_mock.runner.retry_tries = 2 + cfg_mock.runner.retry_wait = 0.1 runner._update_info_running() @@ -677,11 +679,13 @@ def test_runner_update_info_running_raises_on_empty_node_list(): with ( patch("qq_lib.run.runner.socket.getfqdn", return_value="localhost"), - patch("qq_lib.run.runner.CFG.runner.retry_wait", return_value=0.1), + patch("qq_lib.run.runner.CFG") as cfg_mock, pytest.raises( QQError, match="Could not get the list of used nodes from the batch server" ), ): + cfg_mock.runner.retry_tries = 2 + cfg_mock.runner.retry_wait = 0.1 runner._update_info_running() From f416036c749ae091371206555ed9eda31378d42d Mon Sep 17 00:00:00 2001 From: Ladme Date: Thu, 7 May 2026 20:52:01 +0200 Subject: [PATCH 25/43] Default archive dir and archive format are now configurable --- src/qq_lib/core/config.py | 4 ++++ src/qq_lib/submit/cli.py | 4 ++-- src/qq_lib/submit/factory.py | 9 +++++++-- tests/test_submit_factory.py | 32 ++++++++++++++++++++++++++++++-- 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/qq_lib/core/config.py b/src/qq_lib/core/config.py index d716738..abb619d 100644 --- a/src/qq_lib/core/config.py +++ b/src/qq_lib/core/config.py @@ -146,6 +146,10 @@ class LoopJobSettings: # Pattern used for naming loop jobs. pattern: str = "+%04d" + # Pattern used for names of archived files. + archive_format: str = "job%04d" + # Default name of the archive directory. + archive_dir: str = "storage" @dataclass(frozen=True) diff --git a/src/qq_lib/submit/cli.py b/src/qq_lib/submit/cli.py index 8daf8d1..aa85ebf 100644 --- a/src/qq_lib/submit/cli.py +++ b/src/qq_lib/submit/cli.py @@ -289,13 +289,13 @@ def complete_script( "--archive", type=str, default=None, - help=f"Directory name for archiving files from a loop job. Defaults to {click.style('storage', bold=True)}.", + help=f"Directory name for archiving files from a loop job. Defaults to {click.style(f'{CFG.loop_jobs.archive_dir}', bold=True)}.", ) @optgroup.option( "--archive-format", type=str, default=None, - help=f"Filename format for archived files. Defaults to {click.style('job%04d', bold=True)}.", + help=f"Filename format for archived files. Defaults to {click.style(f'{CFG.loop_jobs.archive_format}', bold=True)}.", ) @optgroup.option( "--archive-mode", diff --git a/src/qq_lib/submit/factory.py b/src/qq_lib/submit/factory.py index 57b32e1..0366931 100644 --- a/src/qq_lib/submit/factory.py +++ b/src/qq_lib/submit/factory.py @@ -6,6 +6,7 @@ from qq_lib.batch.interface import BatchInterface 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.properties.depend import Depend from qq_lib.properties.job_type import JobType @@ -173,10 +174,14 @@ def _get_loop_info(self) -> LoopInfo: self._kwargs.get("loop_start") or self._parser.get_loop_start() or 1, self._kwargs.get("loop_end") or self._parser.get_loop_end(), self._input_dir - / (self._kwargs.get("archive") or self._parser.get_archive() or "storage"), + / ( + self._kwargs.get("archive") + or self._parser.get_archive() + or CFG.loop_jobs.archive_dir + ), self._kwargs.get("archive_format") or self._parser.get_archive_format() - or "job%04d", + or CFG.loop_jobs.archive_format, input_dir=self._input_dir, archive_mode=TransferMode.multi_from_str( self._kwargs.get("archive_mode") or "" diff --git a/tests/test_submit_factory.py b/tests/test_submit_factory.py index 4db3700..2d9a104 100644 --- a/tests/test_submit_factory.py +++ b/tests/test_submit_factory.py @@ -7,6 +7,7 @@ import pytest from qq_lib.batch.interface.interface import BatchInterface +from qq_lib.core.config import CFG from qq_lib.core.error import QQError from qq_lib.properties.depend import Depend from qq_lib.properties.job_type import JobType @@ -240,10 +241,35 @@ def test_submitter_factory_get_loop_info_mixed_cli_parser_and_defaults(): assert isinstance(loop_info, LoopInfo) assert loop_info.start == 10 # CLI assert loop_info.end == 50 # parser - assert loop_info.archive == Path("fake_path/storage").resolve() # default + assert ( + loop_info.archive == Path(f"fake_path/{CFG.loop_jobs.archive_dir}").resolve() + ) # default assert loop_info.archive_format == "job%02d" # parser +def test_submitter_factory_get_loop_info_mixed_cli_parser_and_defaults2(): + mock_parser = MagicMock() + mock_parser.get_loop_start.return_value = None + mock_parser.get_loop_end.return_value = 50 + mock_parser.get_archive.return_value = "archive" + mock_parser.get_archive_format.return_value = None + + factory = SubmitterFactory.__new__(SubmitterFactory) + factory._input_dir = Path("fake_path") + factory._parser = mock_parser + factory._kwargs = { + "loop_end": 10, + } + + loop_info = factory._get_loop_info() + + assert isinstance(loop_info, LoopInfo) + assert loop_info.start == 1 # default + assert loop_info.end == 10 # CLI + assert loop_info.archive == Path("fake_path/archive").resolve() # parser + assert loop_info.archive_format == CFG.loop_jobs.archive_format # default + + def test_submitter_factory_get_loop_info_mixed_cli_parser_and_defaults_with_archive_mode(): mock_parser = MagicMock() mock_parser.get_loop_start.return_value = None @@ -262,7 +288,9 @@ def test_submitter_factory_get_loop_info_mixed_cli_parser_and_defaults_with_arch assert isinstance(loop_info, LoopInfo) assert loop_info.start == 10 # CLI assert loop_info.end == 50 # parser - assert loop_info.archive == Path("fake_path/storage").resolve() # default + assert ( + loop_info.archive == Path(f"fake_path/{CFG.loop_jobs.archive_dir}").resolve() + ) # default assert loop_info.archive_format == "job%02d" # parser assert loop_info.archive_mode == [Never()] # CLI From fba1c33a085845e08d251b464e4b17b821960547 Mon Sep 17 00:00:00 2001 From: Ladme Date: Thu, 7 May 2026 20:52:31 +0200 Subject: [PATCH 26/43] Updated readme --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48d3150..03d34ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Handling exceptions that occur when reading a configuration file. - When reading configuration file fails, an exception is no longer raised, instead an error is reported and a default configuration is used. - `CFG` is now a frozen dataclass. +- Default archive directory and archive format are now configurable. *** From 3cbfb8e892a12dcdd931ac44323b5193fb015e39 Mon Sep 17 00:00:00 2001 From: Ladme Date: Thu, 7 May 2026 22:07:22 +0200 Subject: [PATCH 27/43] Interpreters now support arguments --- src/qq_lib/core/interpreter.py | 98 +++++++++++++++++++++ src/qq_lib/properties/info.py | 16 +++- src/qq_lib/run/runner.py | 33 ++----- src/qq_lib/submit/factory.py | 13 ++- src/qq_lib/submit/parser.py | 9 +- src/qq_lib/submit/submitter.py | 7 +- tests/test_core_intepreter.py | 155 +++++++++++++++++++++++++++++++++ tests/test_properties_info.py | 5 ++ tests/test_run_runner.py | 50 ++--------- tests/test_submit_factory.py | 9 +- tests/test_submit_parser.py | 13 ++- tests/test_submit_submitter.py | 3 +- 12 files changed, 321 insertions(+), 90 deletions(-) create mode 100644 src/qq_lib/core/interpreter.py create mode 100644 tests/test_core_intepreter.py diff --git a/src/qq_lib/core/interpreter.py b/src/qq_lib/core/interpreter.py new file mode 100644 index 0000000..21db36b --- /dev/null +++ b/src/qq_lib/core/interpreter.py @@ -0,0 +1,98 @@ +# Released under MIT License. +# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab + +import shutil +import socket +from dataclasses import dataclass, field +from typing import Any + +from qq_lib.core.config import CFG +from qq_lib.core.error import QQError + + +@dataclass(frozen=True) +class Interpreter: + """Configuration for the interpreter used to execute a job script. + + Attributes: + executable: Name or path of the interpreter executable. + Defaults to the executable from `CFG.runner.default_interpreter`. + arguments: Additional command-line arguments passed to the interpreter. + Defaults to arguments parsed from `CFG.runner.default_interpreter`, + or an empty list if `executable` is provided explicitly. + """ + + executable: str | None = None + arguments: list[str] = field(default_factory=list) + + def __post_init__(self): + """Parse executable and arguments from `CFG.runner.default_interpreter` if no executable was provided.""" + if self.executable is None: + binary, *arguments = CFG.runner.default_interpreter.split() + object.__setattr__(self, "executable", binary) + object.__setattr__(self, "arguments", arguments) + + @classmethod + def from_str(cls, s: str) -> "Interpreter": + """ + Create an Interpreter from a string containing the executable and optional arguments. + + The string is split on whitespace. The first token is used as the executable + and any remaining tokens become arguments. + + Args: + s (str): Space-separated string of the interpreter executable followed by + optional arguments. For example: "python3 -u" or "/usr/bin/bash". + + Returns: + Interpreter:An Interpreter instance with the parsed executable and arguments. + """ + executable, *arguments = s.split() + return cls(executable=executable, arguments=arguments) + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "Interpreter": + """ + Create an Interpreter from a dictionary. + + Args: + d (dict[str, Any]): Dictionary with keys matching the dataclass fields + (`executable` and `arguments`). + + Returns: + Interpreter: An Interpreter instance constructed from the dictionary values. + """ + return cls(**d) + + def to_dict(self) -> dict[str, Any]: + """ + Serialize the Interpreter to a dictionary. + + Returns: + A dictionary with `executable` and `arguments` keys. + """ + return {"executable": self.executable, "arguments": self.arguments} + + def to_command_list(self) -> list[str]: + """ + Resolve the executable to its full path and build the command list. + + Uses `shutil.which` to locate the interpreter on the current node. + The returned list is suitable for use with `subprocess` calls. + + Returns: + list[str]: A list containing the resolved absolute path of the executable + followed by any configured arguments. + + Raises: + QQError: If the interpreter executable cannot be found on the current node. + """ + # enforced in __post_init__ + assert self.executable is not None + + if not (full := shutil.which(self.executable)): + raise QQError( + f"Interpreter '{self.executable}' is not available on node '{socket.getfqdn()}'." + ) + + return [full] + self.arguments diff --git a/src/qq_lib/properties/info.py b/src/qq_lib/properties/info.py index 9a44ff2..421b782 100644 --- a/src/qq_lib/properties/info.py +++ b/src/qq_lib/properties/info.py @@ -26,6 +26,7 @@ from qq_lib.core.common import load_yaml_dumper, load_yaml_loader from qq_lib.core.config import CFG from qq_lib.core.error import QQError +from qq_lib.core.interpreter import Interpreter from qq_lib.core.logger import get_logger from qq_lib.properties.depend import Depend from qq_lib.properties.resubmit_host import ResubmitHost @@ -124,7 +125,7 @@ class Info: resubmit_from: list[ResubmitHost] = field(default_factory=list) # Interpreter to use for running the submitted script - interpreter: str | None = None + interpreter: Interpreter | None = None # Job start time start_time: datetime | None = None @@ -286,6 +287,9 @@ def _to_dict(self) -> dict[str, object]: or f.type == list[Depend] ): result[f.name] = [x.to_str() for x in value] + # convert interpreter + elif f.type == Interpreter or f.type == Interpreter | None: + result[f.name] = value.to_dict() # convert timestamp elif f.type == datetime or f.type == datetime | None: result[f.name] = value.strftime(CFG.date_formats.standard) @@ -351,6 +355,16 @@ def _from_dict(cls, data: dict[str, object]) -> Self: # convert resubmit hosts elif f.type == list[ResubmitHost] and isinstance(value, list): init_kwargs[name] = [ResubmitHost.from_str(x) for x in value] # ty: ignore[invalid-argument-type] + # convert interpreter from string (legacy) + elif (f.type == Interpreter or f.type == Interpreter | None) and isinstance( + value, str + ): + init_kwargs[name] = Interpreter.from_str(value) + # convert interpreter from dict + elif (f.type == Interpreter or f.type == Interpreter | None) and isinstance( + value, dict + ): + init_kwargs[name] = Interpreter.from_dict(value) # ty: ignore[invalid-argument-type] # convert timestamp elif (f.type == datetime or f.type == datetime | None) and isinstance( value, str diff --git a/src/qq_lib/run/runner.py b/src/qq_lib/run/runner.py index 40d33e1..d1596e6 100644 --- a/src/qq_lib/run/runner.py +++ b/src/qq_lib/run/runner.py @@ -24,6 +24,7 @@ QQRunCommunicationError, QQRunFatalError, ) +from qq_lib.core.interpreter import Interpreter from qq_lib.core.logger import get_logger from qq_lib.core.logical_paths import logical_resolve from qq_lib.core.retryer import Retryer @@ -197,10 +198,17 @@ def execute(self) -> int: logger.info(f"Executing script '{script}'.") + # get intepreter if configured, otherwise use the default + interpreter = self._informer.info.interpreter or Interpreter() + + # get the command to execute + command_list = [*interpreter.to_command_list(), str(script)] + logger.debug(f"Command executed using subprocess.Popen: {command_list}") + try: with Path(stdout_log).open("w") as out, Path(stderr_log).open("w") as err: self._process = subprocess.Popen( - [self._get_interpreter(), str(script)], + command_list, stdout=out, stderr=err, text=True, @@ -416,29 +424,6 @@ def _delete_work_dir(self) -> None: wait_seconds=CFG.runner.retry_wait, ).run() - def _get_interpreter(self) -> str: - """ - Resolve the fully qualified path to the job's interpreter. - - Uses the interpreter specified in the job's info if set, otherwise falls - back to the configured default interpreter. The interpreter is resolved - via `shutil.which`, ensuring the returned path is absolute and - executable on the current node. - - Returns: - str: The fully qualified path to the interpreter binary. - - Raises: - QQError: If the interpreter cannot be found on the current node. - """ - interpreter = self._informer.info.interpreter or CFG.runner.default_interpreter - if not (full := shutil.which(interpreter)): - raise QQError( - f"Interpreter '{interpreter}' is not available on node '{socket.getfqdn()}'." - ) - - return full - def _update_info_running(self) -> None: """ Update the qq info file to mark the job as running. diff --git a/src/qq_lib/submit/factory.py b/src/qq_lib/submit/factory.py index 0366931..da112b1 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.interpreter import Interpreter from qq_lib.properties.depend import Depend from qq_lib.properties.job_type import JobType from qq_lib.properties.loop import LoopInfo @@ -287,7 +288,7 @@ def _get_server(self) -> str | None: return None - def _get_interpreter(self) -> str | None: + def _get_interpreter(self) -> Interpreter | None: """ Determine the interpreter to use for running the script. @@ -297,15 +298,13 @@ def _get_interpreter(self) -> str | None: 3. None - the default intepreter Returns: - str | None: The interpreter to use for running the script + Interpreter | None: The interpreter to use for running the script or `None` to use the default intepreter. """ - if interpreter := ( - self._kwargs.get("interpreter") or self._parser.get_interpreter() - ): - return interpreter + if (raw := self._kwargs.get("interpreter")) is not None: + return Interpreter.from_str(raw) - return None + return self._parser.get_interpreter() def _get_resubmit_from(self) -> list[ResubmitHost]: """ diff --git a/src/qq_lib/submit/parser.py b/src/qq_lib/submit/parser.py index 9b9e325..3a89597 100644 --- a/src/qq_lib/submit/parser.py +++ b/src/qq_lib/submit/parser.py @@ -11,6 +11,7 @@ from qq_lib.batch.interface import BatchInterface from qq_lib.core.common import split_files_list, to_snake_case from qq_lib.core.error import QQError +from qq_lib.core.interpreter import Interpreter from qq_lib.core.logger import get_logger from qq_lib.properties.depend import Depend from qq_lib.properties.job_type import JobType @@ -291,15 +292,15 @@ def get_server(self) -> str | None: return None - def get_interpreter(self) -> str | None: + def get_interpreter(self) -> Interpreter | None: """ Get the interpreter that should be used to run the script. Returns: - str | None: The interpreter or `None` if not specified. + Interpreter | None: The interpreter or `None` if not specified. """ - if (interpreter := self._options.get("interpreter")) is not None: - return str(interpreter) + if (raw := self._options.get("interpreter")) is not None: + return Interpreter.from_str(str(raw)) return None diff --git a/src/qq_lib/submit/submitter.py b/src/qq_lib/submit/submitter.py index 153c4b6..134901c 100644 --- a/src/qq_lib/submit/submitter.py +++ b/src/qq_lib/submit/submitter.py @@ -17,6 +17,7 @@ ) from qq_lib.core.config import CFG from qq_lib.core.error import QQError +from qq_lib.core.interpreter import Interpreter from qq_lib.core.logger import get_logger from qq_lib.core.logical_paths import logical_resolve from qq_lib.info.informer import Informer @@ -60,7 +61,7 @@ def __init__( depend: list[Depend] | None = None, transfer_mode: list[TransferMode] | None = None, server: str | None = None, - interpreter: str | None = None, + interpreter: Interpreter | None = None, resubmit_from: list[ResubmitHost] | None = None, ): """ @@ -85,7 +86,7 @@ def __init__( working directory to the input directory. Defaults to [`Success()`]. server (str | None): Optional name of the server to which the job should be submitted. If `None`, the default batch server, as configured by the batch system is used. - intepreter (str | None): Optional executable name or absolute path of the interpreter to use to execute the script. + intepreter (Interpreter | None): Optional interpreter specification to use to execute the script. If not specified, the config default is used. resubmit_from (list[ResubmitHost] | None): List of hosts from which a loop/continuous job should be resubmitted. If not specified, the config or batch system default is used. @@ -318,7 +319,7 @@ def get_server(self) -> str | None: """Get the submission server.""" return self._server - def get_interpreter(self) -> str | None: + def get_interpreter(self) -> Interpreter | None: """Get the interpreter to use for running the script.""" return self._interpreter diff --git a/tests/test_core_intepreter.py b/tests/test_core_intepreter.py new file mode 100644 index 0000000..d941664 --- /dev/null +++ b/tests/test_core_intepreter.py @@ -0,0 +1,155 @@ +# Released under MIT License. +# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab + + +import socket +from pathlib import Path +from unittest.mock import patch + +import pytest + +from qq_lib.core.config import CFG +from qq_lib.core.error import QQError +from qq_lib.core.interpreter import Interpreter + + +def test_interpreter_default_executable_only(): + with patch("qq_lib.core.interpreter.CFG") as mock_cfg: + mock_cfg.runner.default_interpreter = "bash" + interp = Interpreter() + + assert interp.executable == "bash" + assert interp.arguments == [] + + +def test_interpreter_default_with_arguments(): + with patch("qq_lib.core.interpreter.CFG") as mock_cfg: + mock_cfg.runner.default_interpreter = "python3 -u -O" + interp = Interpreter() + + assert interp.executable == "python3" + assert interp.arguments == ["-u", "-O"] + + +def test_interpreter_from_str_executable_only(): + interp = Interpreter.from_str("python3") + assert interp.executable == "python3" + assert interp.arguments == [] + + +def test_interpreter_from_str_executable_with_single_argument(): + interp = Interpreter.from_str("python3 -u") + assert interp.executable == "python3" + assert interp.arguments == ["-u"] + + +def test_interpreter_from_str_executable_with_multiple_arguments(): + interp = Interpreter.from_str("python3 -u -O") + assert interp.executable == "python3" + assert interp.arguments == ["-u", "-O"] + + +def test_interpreter_from_str_absolute_path(): + interp = Interpreter.from_str("/usr/bin/bash --norc --noprofile") + assert interp.executable == "/usr/bin/bash" + assert interp.arguments == ["--norc", "--noprofile"] + + +def test_interpreter_from_str_strips_extra_whitespace(): + interp = Interpreter.from_str(" python3 -u -O ") + assert interp.executable == "python3" + assert interp.arguments == ["-u", "-O"] + + +def test_interpreter_from_str_empty_string_raises(): + with pytest.raises(ValueError): + Interpreter.from_str("") + + +def test_interpreter_from_str_whitespace_only_raises(): + with pytest.raises(ValueError): + Interpreter.from_str(" ") + + +def test_intepreter_from_dict_full(): + interp = Interpreter.from_dict({"executable": "bash", "arguments": ["-x", "-e"]}) + assert interp.executable == "bash" + assert interp.arguments == ["-x", "-e"] + + +def test_interpreter_from_dict_executable_only(): + interp = Interpreter.from_dict({"executable": "python3", "arguments": []}) + assert interp.executable == "python3" + assert interp.arguments == [] + + +def test_interpreter_from_dict_with_defaults(): + interp = Interpreter.from_dict({}) + assert interp.executable == CFG.runner.default_interpreter + assert interp.arguments == [] + + +def test_intepreter_to_dict_default(): + interp = Interpreter() + result = interp.to_dict() + assert result == {"executable": CFG.runner.default_interpreter, "arguments": []} + + +def test_interpreter_to_dict_custom(): + interp = Interpreter(executable="bash", arguments=["-x"]) + result = interp.to_dict() + assert result == {"executable": "bash", "arguments": ["-x"]} + + +def test_intepreter_to_dict_from_dict_round_trip(): + original = Interpreter(executable="python3", arguments=["-u", "-O"]) + reconstructed = Interpreter.from_dict(original.to_dict()) + assert reconstructed == original + + +def test_interpreter_from_dict_to_dict_round_trip(): + d = {"executable": "/usr/bin/bash", "arguments": ["--norc"]} + assert Interpreter.from_dict(d).to_dict() == d + + +def test_interpreter_from_str_to_dict_round_trip(): + interp = Interpreter.from_str("python3 -u") + assert interp.to_dict() == {"executable": "python3", "arguments": ["-u"]} + + +def test_interpreter_to_command_list_resolves_to_full_path(): + interp = Interpreter(executable="python3", arguments=[]) + result = interp.to_command_list() + + assert Path(result[0]).is_absolute() + assert result[0].endswith("python3") + assert len(result) == 1 + + +def test_interpreter_to_command_list_full_path_stays_unchanged(): + interp = Interpreter(executable="/usr/bin/bash", arguments=[]) + result = interp.to_command_list() + + assert result[0] == "/usr/bin/bash" + assert len(result) == 1 + + +def test_interpreter_to_command_list_raises_when_executable_not_found(): + interp = Interpreter(executable="nonexistent_interpreter_xyz") + + with pytest.raises(QQError, match="not available on node"): + interp.to_command_list() + + +def test_interpreter_to_command_list_error_includes_executable_name(): + interp = Interpreter(executable="fake_executable_abc") + + with pytest.raises(QQError, match="fake_executable_abc"): + interp.to_command_list() + + +def test_interpreter_to_command_list_error_includes_node_name(): + interp = Interpreter(executable="nonexistent_interpreter_xyz") + + with pytest.raises(QQError, match=socket.getfqdn()): + interp.to_command_list() diff --git a/tests/test_properties_info.py b/tests/test_properties_info.py index 3589305..159d49e 100644 --- a/tests/test_properties_info.py +++ b/tests/test_properties_info.py @@ -13,6 +13,7 @@ from qq_lib.batch.pbs import PBS from qq_lib.batch.slurmit4i import SlurmIT4I from qq_lib.core.error import QQError +from qq_lib.core.interpreter import Interpreter from qq_lib.properties.info import CFG, Info from qq_lib.properties.job_type import JobType from qq_lib.properties.resources import Resources @@ -54,6 +55,7 @@ def sample_info(sample_resources): work_dir=Path("/scratch/job_12345.fake.server.com"), account="fake-account", resubmit_from=[InputHost(), ExplicitHost("node01.fake.server.com")], + interpreter=Interpreter(executable="python", arguments=["-u"]), ) @@ -69,6 +71,7 @@ def test_to_dict_skips_none(sample_info): assert result["work_dir"] == "/scratch/job_12345.fake.server.com" assert result["input_dir"] == "/shared/storage" assert result["submission_time"] == "2025-09-21 12:00:00" + assert result["interpreter"] == {"executable": "python", "arguments": ["-u"]} def test_to_dict_contains_all_non_none_fields(sample_info): @@ -92,6 +95,7 @@ def test_to_dict_contains_all_non_none_fields(sample_info): "account", "transfer_mode", "resubmit_from", + "interpreter", } assert expected_fields.issubset(result.keys()) @@ -209,6 +213,7 @@ def test_from_dict_roundtrip(sample_info): "stderr_file", "transfer_mode", "resubmit_from", + "interpreter", ]: assert getattr(reconstructed, field_name) == getattr(sample_info, field_name) assert type(getattr(reconstructed, field_name)) is type( diff --git a/tests/test_run_runner.py b/tests/test_run_runner.py index 9451ab0..1250118 100644 --- a/tests/test_run_runner.py +++ b/tests/test_run_runner.py @@ -4,7 +4,6 @@ import os import shutil import signal -import sys from datetime import datetime from pathlib import Path from unittest.mock import MagicMock, patch @@ -17,6 +16,7 @@ QQRunCommunicationError, QQRunFatalError, ) +from qq_lib.core.interpreter import Interpreter from qq_lib.properties.job_type import JobType from qq_lib.properties.states import NaiveState from qq_lib.run.runner import CFG, Runner, log_fatal_error_and_exit @@ -1166,7 +1166,9 @@ def test_runner_execute_updates_info_and_runs_script_using_python(tmp_path): runner._update_info_running = MagicMock() runner._informer = MagicMock() runner._informer.info.script_name = str(script_file) - runner._informer.info.interpreter = "python" + runner._informer.info.interpreter = Interpreter( + executable="python", arguments=["-u"] + ) runner._informer.info.stdout_file = stdout_file runner._informer.info.stderr_file = stderr_file @@ -1196,7 +1198,7 @@ def test_runner_execute_updates_info_and_runs_script_using_python(tmp_path): which_mock.assert_called_once_with("python") runner._update_info_running.assert_called_once() popen_mock.assert_called_once_with( - ["/usr/bin/python", str(script_file.resolve())], + ["/usr/bin/python", "-u", str(script_file.resolve())], stdout=mock_file, stderr=mock_file, text=True, @@ -1662,45 +1664,3 @@ def test_runner_copy_files_calls_sync_selected(tmp_path): ) assert runner._batch_system.sync_selected.call_count == 2 - - -def test_runner_get_interpreter_returns_full_path_when_interpreter_set(): - informer_mock = MagicMock() - informer_mock.info.interpreter = Path(sys.executable).name - - runner = Runner.__new__(Runner) - runner._informer = informer_mock - - assert runner._get_interpreter() == shutil.which(Path(sys.executable).name) - - -def test_runner_get_interpreter_returns_absolute_path(): - informer_mock = MagicMock() - informer_mock.info.interpreter = Path(sys.executable).name - - runner = Runner.__new__(Runner) - runner._informer = informer_mock - - assert Path(runner._get_interpreter()).is_absolute() - - -def test_runner_get_interpreter_falls_back_to_default_interpreter(): - informer_mock = MagicMock() - informer_mock.info.interpreter = None - - runner = Runner.__new__(Runner) - runner._informer = informer_mock - - result = runner._get_interpreter() - assert result == shutil.which(CFG.runner.default_interpreter) - - -def test_runner_get_interpreter_raises_when_interpreter_not_found(): - informer_mock = MagicMock() - informer_mock.info.interpreter = "nonexistent-interpreter-xyz" - - runner = Runner.__new__(Runner) - runner._informer = informer_mock - - with pytest.raises(QQError, match="nonexistent-interpreter-xyz"): - runner._get_interpreter() diff --git a/tests/test_submit_factory.py b/tests/test_submit_factory.py index 2d9a104..4ecc3b2 100644 --- a/tests/test_submit_factory.py +++ b/tests/test_submit_factory.py @@ -9,6 +9,7 @@ from qq_lib.batch.interface.interface import BatchInterface from qq_lib.core.config import CFG from qq_lib.core.error import QQError +from qq_lib.core.interpreter import Interpreter from qq_lib.properties.depend import Depend from qq_lib.properties.job_type import JobType from qq_lib.properties.loop import LoopInfo @@ -564,7 +565,7 @@ def test_submitter_factory_get_server_returns_none_if_no_cli_nor_parser(): def test_submitter_factory_get_interpreter_uses_cli_over_parser(): mock_parser = MagicMock() - parser_interpreter = "python" + parser_interpreter = Interpreter.from_str("python") mock_parser.get_interpreter.return_value = parser_interpreter factory = SubmitterFactory.__new__(SubmitterFactory) @@ -572,12 +573,12 @@ def test_submitter_factory_get_interpreter_uses_cli_over_parser(): factory._kwargs = {"interpreter": "bash"} result = factory._get_interpreter() - assert result == "bash" + assert result == Interpreter(executable="bash") def test_submitter_factory_get_interpreter_uses_parser_if_no_cli(): mock_parser = MagicMock() - parser_interpreter = "python" + parser_interpreter = Interpreter.from_str("python") mock_parser.get_interpreter.return_value = parser_interpreter factory = SubmitterFactory.__new__(SubmitterFactory) @@ -585,7 +586,7 @@ def test_submitter_factory_get_interpreter_uses_parser_if_no_cli(): factory._kwargs = {} result = factory._get_interpreter() - assert result == "python" + assert result == Interpreter(executable="python") def test_submitter_factory_get_interpreter_returns_none_if_no_cli_no_parser(): diff --git a/tests/test_submit_parser.py b/tests/test_submit_parser.py index 27b2cb7..e8c13af 100644 --- a/tests/test_submit_parser.py +++ b/tests/test_submit_parser.py @@ -12,6 +12,7 @@ from qq_lib.batch.interface.interface import BatchInterface from qq_lib.batch.pbs import PBS from qq_lib.core.error import QQError +from qq_lib.core.interpreter import Interpreter from qq_lib.properties.depend import Depend from qq_lib.properties.job_type import JobType from qq_lib.properties.resources import Resources @@ -72,6 +73,8 @@ def test_parser_init(tmp_path): ("# Qq key=value", ["key", "value"]), # multiple equals, split only once ("# qq name=John=Doe", ["name", "John=Doe"]), + # multiple spaces + ("# qq key value is this", ["key", "value is this"]), # inline comments ("# qq key=value # key is value", ["key", "value"]), ("# qq key value# key is value", ["key", "value"]), @@ -421,7 +424,15 @@ def test_parser_get_interpreter_value(): parser._options = {"interpreter": "/usr/bin/python"} result = parser.get_interpreter() - assert result == "/usr/bin/python" + assert result == Interpreter(executable="/usr/bin/python", arguments=[]) + + +def test_parser_get_interpreter_value_with_arguments(): + parser = Parser.__new__(Parser) + parser._options = {"interpreter": "python3 -u -O"} + + result = parser.get_interpreter() + assert result == Interpreter(executable="python3", arguments=["-u", "-O"]) def test_parser_get_resubmit_from_empty_list(): diff --git a/tests/test_submit_submitter.py b/tests/test_submit_submitter.py index 69e2b83..8469ce3 100644 --- a/tests/test_submit_submitter.py +++ b/tests/test_submit_submitter.py @@ -12,6 +12,7 @@ from qq_lib.batch.pbs.pbs import PBS from qq_lib.core.error import QQError +from qq_lib.core.interpreter import Interpreter from qq_lib.info.informer import Informer from qq_lib.properties.depend import Depend, DependType from qq_lib.properties.job_type import JobType @@ -597,7 +598,7 @@ def test_submitter_submit_calls_all_steps_and_returns_job_id(tmp_path): submitter._transfer_mode = [Success()] submitter._info_file = tmp_path / f"{submitter._job_name}.qqinfo" submitter._server = None - submitter._interpreter = "python3" + submitter._interpreter = Interpreter.from_str("python3") submitter._resubmit_from = [] env_vars = {CFG.env_vars.guard: "true"} From 71f3785b3ca34362e658bfeba61f03b87d4be115 Mon Sep 17 00:00:00 2001 From: Ladme Date: Thu, 7 May 2026 22:07:34 +0200 Subject: [PATCH 28/43] Updated changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03d34ca..98b7867 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ ## Version 0.11 - Updated the installation scripts to more clearly report issues that occured during the install. +### Better support for non-bash interpreters +- Interpreters now support additional command-line arguments. + ### Resubmitting with fallback hosts - Resubmission of loop and continuous jobs now supports multiple fallback hosts. Previously, jobs were resubmitted from a single machine (the input machine or the working node, depending on the batch system). A list of hosts can now be specified via `--resubmit-from` or in the config file; they are tried in order until one succeeds, making resubmission more resilient to individual machine failures. @@ -14,6 +17,7 @@ - When reading configuration file fails, an exception is no longer raised, instead an error is reported and a default configuration is used. - `CFG` is now a frozen dataclass. - Default archive directory and archive format are now configurable. +- Changed internal representation of the interpreter specified to execute the script. *** From 07b19abeb14b3f2416cf86274a45f15ebefd7970 Mon Sep 17 00:00:00 2001 From: Ladme Date: Thu, 7 May 2026 22:11:10 +0200 Subject: [PATCH 29/43] Doc string formatting --- src/qq_lib/core/interpreter.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/qq_lib/core/interpreter.py b/src/qq_lib/core/interpreter.py index 21db36b..0a949bb 100644 --- a/src/qq_lib/core/interpreter.py +++ b/src/qq_lib/core/interpreter.py @@ -12,7 +12,8 @@ @dataclass(frozen=True) class Interpreter: - """Configuration for the interpreter used to execute a job script. + """ + Configuration for the interpreter used to execute a job script. Attributes: executable: Name or path of the interpreter executable. From 939cfc6616b7ca90f47748c8ce27d020081f75fc Mon Sep 17 00:00:00 2001 From: Ladme Date: Sat, 9 May 2026 10:14:06 +0200 Subject: [PATCH 30/43] Respawner refactoring, docs, and tests --- src/qq_lib/properties/depend.py | 32 ++- src/qq_lib/respawn/respawner.py | 104 ++++++---- tests/test_properties_depend.py | 115 ++++++++++- tests/test_respawn_cli.py | 186 +++++++++++++++++ tests/test_respawn_respawner.py | 355 ++++++++++++++++++++++++++++++++ 5 files changed, 754 insertions(+), 38 deletions(-) create mode 100644 tests/test_respawn_cli.py create mode 100644 tests/test_respawn_respawner.py diff --git a/src/qq_lib/properties/depend.py b/src/qq_lib/properties/depend.py index 7a197d1..d760080 100644 --- a/src/qq_lib/properties/depend.py +++ b/src/qq_lib/properties/depend.py @@ -14,6 +14,7 @@ from enum import Enum from typing import Self +from qq_lib.batch.interface import AnyBatchClass from qq_lib.core.error import QQError from qq_lib.core.logger import get_logger @@ -98,7 +99,7 @@ def to_str(self) -> str: ) -@dataclass +@dataclass(frozen=True) class Depend: """ Representation of a parsed job dependency. @@ -174,3 +175,32 @@ def to_str(self) -> str: the colon-separated list of job IDs. """ return f"{self.type.to_str()}={':'.join(self.jobs)}" + + +def filter_dependencies( + batch_system: AnyBatchClass, dependencies: list[Depend] +) -> list[Depend]: + """ + Filter dependencies to only include those that are still present in the batch system. + + Args: + batch_system (AnyBatchClass): The batch system to check job presence against. + dependencies (list[Depend]): The list of dependencies to filter. + + Returns: + list[Depend]: The filtered list of dependencies. + """ + filtered: list[Depend] = [] + for depend in dependencies: + # get jobs that are still present in the batch system + valid_jobs = [ + job_id + for job_id in depend.jobs + if not (batch_system.get_batch_job(job_id)).is_empty() + ] + + if valid_jobs: + filtered.append(Depend(type=depend.type, jobs=valid_jobs)) + + logger.debug(f"Filtered dependencies: {filtered}.") + return filtered diff --git a/src/qq_lib/respawn/respawner.py b/src/qq_lib/respawn/respawner.py index 45dd273..debc82d 100644 --- a/src/qq_lib/respawn/respawner.py +++ b/src/qq_lib/respawn/respawner.py @@ -5,15 +5,23 @@ from qq_lib.core.error import QQError, QQNotSuitableError from qq_lib.core.logger import get_logger from qq_lib.core.operator import Operator -from qq_lib.properties.depend import Depend +from qq_lib.info import Informer +from qq_lib.properties.depend import filter_dependencies from qq_lib.properties.loop import LoopInfo from qq_lib.properties.states import RealState from qq_lib.submit import Submitter +from qq_lib.wipe import Wiper logger = get_logger(__name__) class Respawner(Operator): + """ + Respawns a failed or killed job by cleaning up and resubmitting it with the same parameters. + + For loop jobs, the archive directory is checked for consistency before respawning. + """ + def ensure_suitable(self) -> None: """ Verify that the job is in a state where it can be respawned. @@ -27,61 +35,85 @@ def ensure_suitable(self) -> None: ) def respawn(self) -> str: + """ + Respawn the job by cleaning up and submitting a fresh copy. + + Returns: + str: The job ID of the newly submitted job. + + Raises: + QQError: If the submitter cannot be built or the job cannot be submitted. + """ informer = self.get_informer() + submitter = self._build_submitter(informer) + input_dir = self._info_file.parent - dependencies = self._handle_dependencies(informer.info.depend) + # attempt to remove the working directory + try: + wiper = Wiper.from_informer(informer) + wiper.ensure_suitable() + wiper.wipe() + except QQNotSuitableError: + pass + except QQError as e: + logger.warning(f"Failed to remove working directory: {e}") + + # clear files from the input directory + clearer = Clearer(input_dir) + clearer.clear() + + # submit a new job + return submitter.submit() + + def _build_submitter(self, informer: Informer) -> Submitter: + """ + Construct a Submitter configured for respawning. + + All original job parameters are preserved. Dependencies are filtered + to only include jobs still present in the batch system. For loop jobs, + the archive directory is checked for consistency before proceeding. + + Args: + informer (Informer): The informer instance holding job metadata. + + Returns: + Submitter: A configured Submitter ready to submit the job. + + Raises: + QQError: If the loop job archive is inconsistent with the current cycle. + """ if (loop_info := informer.info.loop_info) is not None: self._ensure_archive_consistent(loop_info) - submitter = Submitter( + return Submitter( batch_system=informer.batch_system, queue=informer.info.queue, account=informer.info.account, - script=input_dir / informer.info.script_name, + script=self._info_file.parent / informer.info.script_name, job_type=informer.info.job_type, resources=informer.info.resources, - loop_info=informer.info.loop_info, + loop_info=loop_info, exclude=informer.info.excluded_files, include=informer.info.included_files, - depend=dependencies, + # we need to remove dependencies that are no longer present in the batch system + depend=filter_dependencies(informer.batch_system, informer.info.depend), transfer_mode=informer.info.transfer_mode, server=informer.info.server, interpreter=informer.info.interpreter, ) - # clear files from the input directory - clearer = Clearer(input_dir) - clearer.clear() - - # respawn the job - return submitter.submit() - - def _handle_dependencies(self, dependencies: list[Depend]) -> list[Depend]: + @staticmethod + def _ensure_archive_consistent(loop_info: LoopInfo) -> None: """ - Removes jobs from dependencies that are no longer present in the batch system. + Verify that the current loop cycle matches the archive contents. - Without removing these jobs, the respawned job would immediately fail. - """ - BatchSystem = self._informer.batch_system - - filtered = [] - for depend in dependencies: - # get jobs that are still present in the batch system - valid_jobs = [ - job.get_id() - for job_id in depend.jobs - if not (job := BatchSystem.get_batch_job(job_id)).is_empty() - ] - if valid_jobs: - filtered.append(Depend(depend.type, valid_jobs)) - - logger.debug(f"Filtered dependencies: {filtered}.") - return filtered - - def _ensure_archive_consistent(self, loop_info: LoopInfo) -> None: - """ - Ensure that the current loop job cycle matches what we would expect based on the contents of the archive directory. + Args: + loop_info (LoopInfo): Loop job metadata. + + Raises: + QQError: If the cycle determined from the archive does not match + the current cycle in the loop metadata. """ if ( archive_cycle := loop_info.determine_cycle_from_archive() diff --git a/tests/test_properties_depend.py b/tests/test_properties_depend.py index d7722f7..10d5a99 100644 --- a/tests/test_properties_depend.py +++ b/tests/test_properties_depend.py @@ -2,10 +2,14 @@ # Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab +from typing import cast +from unittest.mock import MagicMock + import pytest +from qq_lib.batch.interface import AnyBatchClass from qq_lib.core.error import QQError -from qq_lib.properties.depend import Depend, DependType +from qq_lib.properties.depend import Depend, DependType, filter_dependencies @pytest.mark.parametrize( @@ -228,3 +232,112 @@ def test_depend_to_str_all_depend_types(): for raw, expected in data: dep = Depend.from_str(raw) assert dep.to_str() == expected + + +@pytest.fixture +def batch_system() -> AnyBatchClass: + return cast("AnyBatchClass", MagicMock()) + + +def test_filter_dependencies_keeps_valid_jobs(batch_system): + job = MagicMock() + job.is_empty.return_value = False + batch_system.get_batch_job.return_value = job + + deps = [Depend(type=DependType.AFTER_SUCCESS, jobs=["123"])] + result = filter_dependencies(batch_system, deps) + + assert len(result) == 1 + assert result[0].jobs == ["123"] + + +def test_filter_dependencies_removes_missing_jobs(batch_system): + job = MagicMock() + job.is_empty.return_value = True + batch_system.get_batch_job.return_value = job + + deps = [Depend(type=DependType.AFTER_SUCCESS, jobs=["123"])] + result = filter_dependencies(batch_system, deps) + + assert result == [] + + +def test_filter_dependencies_removes_missing_jobs_from_multi_job_depend(batch_system): + valid_job = MagicMock() + valid_job.is_empty.return_value = False + + missing_job = MagicMock() + missing_job.is_empty.return_value = True + + batch_system.get_batch_job.side_effect = lambda jid: ( + valid_job if jid == "123" else missing_job + ) + + deps = [Depend(type=DependType.AFTER_SUCCESS, jobs=["123", "456", "789"])] + result = filter_dependencies(batch_system, deps) + + assert len(result) == 1 + assert result[0].jobs == ["123"] + + +def test_filter_dependencies_drops_depend_entirely_when_all_jobs_missing(batch_system): + job = MagicMock() + job.is_empty.return_value = True + batch_system.get_batch_job.return_value = job + + deps = [Depend(type=DependType.AFTER_SUCCESS, jobs=["123", "456"])] + result = filter_dependencies(batch_system, deps) + + assert result == [] + + +def test_filter_dependencies_preserves_depend_type(batch_system): + job = MagicMock() + job.is_empty.return_value = False + job.get_id.return_value = "1" + batch_system.get_batch_job.return_value = job + + deps = [ + Depend(type=DependType.AFTER_START, jobs=["1"]), + Depend(type=DependType.AFTER_FAILURE, jobs=["1"]), + Depend(type=DependType.AFTER_COMPLETION, jobs=["1"]), + ] + result = filter_dependencies(batch_system, deps) + + assert [d.type for d in result] == [ + DependType.AFTER_START, + DependType.AFTER_FAILURE, + DependType.AFTER_COMPLETION, + ] + + +def test_filter_dependencies_handles_multiple_depends(batch_system): + valid_job = MagicMock() + valid_job.is_empty.return_value = False + + missing_job = MagicMock() + missing_job.is_empty.return_value = True + + batch_system.get_batch_job.side_effect = lambda jid: ( + valid_job if jid == "1" else missing_job + ) + + deps = [ + Depend(type=DependType.AFTER_SUCCESS, jobs=["1"]), + Depend(type=DependType.AFTER_FAILURE, jobs=["2"]), + Depend(type=DependType.AFTER_COMPLETION, jobs=["1", "3"]), + ] + result = filter_dependencies(batch_system, deps) + + assert len(result) == 2 + assert result[0].type == DependType.AFTER_SUCCESS + assert result[0].jobs == ["1"] + assert result[1].type == DependType.AFTER_COMPLETION + assert result[1].jobs == ["1"] + + +def test_filter_dependencies_empty_input(batch_system): + result = filter_dependencies(batch_system, []) + + assert result == [] + batch_system.get_batch_job.assert_not_called() diff --git a/tests/test_respawn_cli.py b/tests/test_respawn_cli.py new file mode 100644 index 0000000..376b937 --- /dev/null +++ b/tests/test_respawn_cli.py @@ -0,0 +1,186 @@ +# Released under MIT License. +# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab + + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from qq_lib.core.config import CFG +from qq_lib.core.error import QQError, QQNotSuitableError +from qq_lib.properties.states import RealState +from qq_lib.respawn.cli import respawn, respawn_job +from qq_lib.respawn.respawner import Respawner + + +@pytest.fixture +def informer_for_respawn(): + informer = MagicMock() + informer.info.job_id = "111" + informer.get_info_file.return_value = Path("/tmp/input/job.qqinfo") + informer.info.input_machine = "submit-node" + return informer + + +def test_respawn_job_succeeds_when_failed(informer_for_respawn): + informer_for_respawn.get_real_state.return_value = RealState.FAILED + + with ( + patch.object(Respawner, "respawn", return_value="222"), + patch.object(Respawner, "print_info"), + patch("qq_lib.respawn.cli.logger") as mock_logger, + ): + respawn_job(informer_for_respawn) + + mock_logger.info.assert_any_call("Job '111' successfully respawned as '222'.") + + +def test_respawn_job_succeeds_when_killed(informer_for_respawn): + informer_for_respawn.get_real_state.return_value = RealState.KILLED + + with ( + patch.object(Respawner, "respawn", return_value="222"), + patch.object(Respawner, "print_info"), + patch("qq_lib.respawn.cli.logger") as mock_logger, + ): + respawn_job(informer_for_respawn) + + mock_logger.info.assert_any_call("Job '111' successfully respawned as '222'.") + + +@pytest.mark.parametrize( + "state", + [s for s in RealState if s not in {RealState.FAILED, RealState.KILLED}], +) +def test_respawn_job_raises_when_not_suitable(informer_for_respawn, state): + informer_for_respawn.get_real_state.return_value = state + + with ( + patch.object(Respawner, "print_info"), + pytest.raises(QQNotSuitableError, match="cannot be respawned"), + ): + respawn_job(informer_for_respawn) + + +@pytest.mark.parametrize( + "state", + [s for s in RealState if s not in {RealState.FAILED, RealState.KILLED}], +) +def test_respawn_job_does_not_respawn_when_not_suitable(informer_for_respawn, state): + informer_for_respawn.get_real_state.return_value = state + + with ( + patch.object(Respawner, "respawn") as mock_respawn, + patch.object(Respawner, "print_info"), + pytest.raises(QQNotSuitableError), + ): + respawn_job(informer_for_respawn) + + mock_respawn.assert_not_called() + + +def test_respawn_job_prints_info_before_respawning(informer_for_respawn): + informer_for_respawn.get_real_state.return_value = RealState.FAILED + call_order = [] + + with ( + patch.object( + Respawner, "print_info", side_effect=lambda _: call_order.append("print") + ), + patch.object( + Respawner, + "respawn", + side_effect=lambda: (call_order.append("respawn"), "222")[1], + ), + ): + respawn_job(informer_for_respawn) + + assert call_order == ["print", "respawn"] + + +def test_respawn_job_propagates_respawn_error(informer_for_respawn): + informer_for_respawn.get_real_state.return_value = RealState.FAILED + + with ( + patch.object(Respawner, "print_info"), + patch.object(Respawner, "respawn", side_effect=QQError("submission failed")), + pytest.raises(QQError, match="submission failed"), + ): + respawn_job(informer_for_respawn) + + +def test_respawn_invokes_repeater_and_exits_success(tmp_path): + dummy_file = tmp_path / "info.qq" + dummy_file.write_text("dummy") + + runner = CliRunner() + repeater_mock = MagicMock() + informer_mock = MagicMock() + + with ( + patch("qq_lib.respawn.cli.get_info_files", return_value=[dummy_file]), + patch("qq_lib.respawn.cli.Informer.from_file", return_value=informer_mock), + patch("qq_lib.respawn.cli.Repeater", return_value=repeater_mock), + patch("qq_lib.respawn.cli.logger"), + ): + result = runner.invoke(respawn, []) + + assert result.exit_code == 0 + + calls = [c[0][0] for c in repeater_mock.on_exception.call_args_list] + assert QQNotSuitableError in calls + assert QQError in calls + + repeater_mock.run.assert_called_once() + + +def test_respawn_with_job_id_invokes_repeater(): + runner = CliRunner() + repeater_mock = MagicMock() + informer_mock = MagicMock() + + with ( + patch("qq_lib.respawn.cli.Informer.from_job_id", return_value=informer_mock), + patch("qq_lib.respawn.cli.Repeater", return_value=repeater_mock), + patch("qq_lib.respawn.cli.logger"), + ): + result = runner.invoke(respawn, ["12345"]) + + assert result.exit_code == 0 + repeater_mock.run.assert_called_once() + + +def test_respawn_catches_qqerror_and_exits_with_default_code(): + runner = CliRunner() + repeater_mock = MagicMock() + repeater_mock.run.side_effect = QQError("error occurred") + informer_mock = MagicMock() + + with ( + patch("qq_lib.respawn.cli.Informer.from_job_id", return_value=informer_mock), + patch("qq_lib.respawn.cli.Repeater", return_value=repeater_mock), + patch("qq_lib.respawn.cli.logger") as mock_logger, + ): + result = runner.invoke(respawn, ["12345"]) + + assert result.exit_code == CFG.exit_codes.default + mock_logger.error.assert_called_once_with(repeater_mock.run.side_effect) + + +def test_respawn_catches_generic_exception_and_exits_with_unexpected_error_code(): + runner = CliRunner() + repeater_mock = MagicMock() + repeater_mock.run.side_effect = Exception("critical error") + informer_mock = MagicMock() + + with ( + patch("qq_lib.respawn.cli.Informer.from_job_id", return_value=informer_mock), + patch("qq_lib.respawn.cli.Repeater", return_value=repeater_mock), + patch("qq_lib.respawn.cli.logger") as mock_logger, + ): + result = runner.invoke(respawn, ["12345"]) + + assert result.exit_code == CFG.exit_codes.unexpected_error + mock_logger.critical.assert_called_once() diff --git a/tests/test_respawn_respawner.py b/tests/test_respawn_respawner.py new file mode 100644 index 0000000..0eeae4d --- /dev/null +++ b/tests/test_respawn_respawner.py @@ -0,0 +1,355 @@ +# Released under MIT License. +# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab + + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from qq_lib.core.error import QQError, QQNotSuitableError +from qq_lib.properties.depend import Depend, DependType +from qq_lib.properties.loop import LoopInfo +from qq_lib.properties.states import RealState +from qq_lib.respawn.respawner import Respawner + + +@pytest.fixture +def make_loop_info(tmp_path): + def _make(current: int, start: int = 1, archive_files: list[str] | None = None): + archive = tmp_path / "archive" + archive.mkdir(exist_ok=True) + for name in archive_files or []: + (archive / name).touch() + return LoopInfo( + current=current, + start=start, + end=10, + archive=archive, + archive_format="job%04d", + ) + + return _make + + +def test_respawner_ensure_archive_consistent_passes_when_cycles_match(make_loop_info): + loop_info = make_loop_info(current=3, start=1, archive_files=["job0003.init"]) + Respawner._ensure_archive_consistent(loop_info) + + +def test_respawner_ensure_archive_consistent_raises_when_cycles_differ(make_loop_info): + loop_info = make_loop_info(current=2, start=1, archive_files=["job0003.init"]) + + with pytest.raises(QQError, match="cycle '2'.*cycle '3'"): + Respawner._ensure_archive_consistent(loop_info) + + +def test_respawner_ensure_archive_consistent_passes_when_archive_empty_and_current_is_one( + make_loop_info, +): + loop_info = make_loop_info(current=1, start=1, archive_files=[]) + + Respawner._ensure_archive_consistent(loop_info) + + +def test_respawner_ensure_archive_consistent_passes_when_archive_empty_and_current_is_starting( + make_loop_info, +): + loop_info = make_loop_info(current=8, start=8, archive_files=[]) + + Respawner._ensure_archive_consistent(loop_info) + + +def test_respawner_ensure_archive_consistent_raises_when_archive_empty_and_current_is_not_starting( + make_loop_info, +): + loop_info = make_loop_info(current=3, start=2, archive_files=[]) + + with pytest.raises(QQError, match="cycle '3'.*cycle '2'"): + Respawner._ensure_archive_consistent(loop_info) + + +def test_respawner_ensure_archive_consistent_passes_when_archive_does_not_exist_and_current_is_starting( + tmp_path, +): + loop_info = LoopInfo( + current=3, + start=3, + end=10, + archive=tmp_path / "nonexistent", + archive_format="job%04d", + ) + + Respawner._ensure_archive_consistent(loop_info) + + +def test_respawner_ensure_archive_consistent_raises_when_archive_does_not_exist_and_current_is_not_starting( + tmp_path, +): + loop_info = LoopInfo( + current=5, + start=1, + end=10, + archive=tmp_path / "nonexistent", + archive_format="job%04d", + ) + + with pytest.raises(QQError, match="cycle '5'.*cycle '1'"): + Respawner._ensure_archive_consistent(loop_info) + + +@pytest.fixture +def respawner_with_info_file(): + respawner = Respawner.__new__(Respawner) + respawner._info_file = Path("/tmp/input/job.qqinfo") + return respawner + + +def test_respawner_build_submitter_creates_submitter_with_correct_params( + respawner_with_info_file, +): + informer = MagicMock() + informer.info.loop_info = None + informer.info.depend = [] + + dependencies = [Depend(type=DependType.AFTER_SUCCESS, jobs=["12345"])] + + with ( + patch("qq_lib.respawn.respawner.Submitter") as mock_submitter_cls, + patch( + "qq_lib.respawn.respawner.filter_dependencies", + return_value=dependencies, + ), + ): + respawner_with_info_file._build_submitter(informer) + + mock_submitter_cls.assert_called_once_with( + batch_system=informer.batch_system, + queue=informer.info.queue, + account=informer.info.account, + script=Path("/tmp/input") / informer.info.script_name, + job_type=informer.info.job_type, + resources=informer.info.resources, + loop_info=None, + exclude=informer.info.excluded_files, + include=informer.info.included_files, + depend=dependencies, + transfer_mode=informer.info.transfer_mode, + server=informer.info.server, + interpreter=informer.info.interpreter, + ) + + +def test_respawner_build_submitter_filters_dependencies(respawner_with_info_file): + informer = MagicMock() + informer.info.loop_info = None + informer.info.depend = [ + Depend(type=DependType.AFTER_SUCCESS, jobs=["111", "222"]), + ] + filtered = [Depend(type=DependType.AFTER_SUCCESS, jobs=["111"])] + + with ( + patch("qq_lib.respawn.respawner.Submitter") as mock_submitter_cls, + patch( + "qq_lib.respawn.respawner.filter_dependencies", return_value=filtered + ) as mock_filter, + ): + respawner_with_info_file._build_submitter(informer) + + mock_filter.assert_called_once_with(informer.batch_system, informer.info.depend) + assert mock_submitter_cls.call_args.kwargs["depend"] == filtered + + +def test_respawner_build_submitter_calls_ensure_archive_consistent_for_loop_job( + respawner_with_info_file, +): + informer = MagicMock() + loop_info = MagicMock() + informer.info.loop_info = loop_info + + with ( + patch("qq_lib.respawn.respawner.Submitter"), + patch("qq_lib.respawn.respawner.filter_dependencies", return_value=[]), + patch.object(Respawner, "_ensure_archive_consistent") as mock_ensure, + ): + respawner_with_info_file._build_submitter(informer) + + mock_ensure.assert_called_once_with(loop_info) + + +def test_respawner_build_submitter_does_not_call_ensure_archive_for_non_loop_job( + respawner_with_info_file, +): + informer = MagicMock() + informer.info.loop_info = None + + with ( + patch("qq_lib.respawn.respawner.Submitter"), + patch("qq_lib.respawn.respawner.filter_dependencies", return_value=[]), + patch.object(Respawner, "_ensure_archive_consistent") as mock_ensure, + ): + respawner_with_info_file._build_submitter(informer) + + mock_ensure.assert_not_called() + + +def test_respawner_build_submitter_raises_when_archive_inconsistent( + respawner_with_info_file, +): + informer = MagicMock() + informer.info.loop_info = MagicMock() + + with ( + patch("qq_lib.respawn.respawner.Submitter"), + patch("qq_lib.respawn.respawner.filter_dependencies", return_value=[]), + patch.object( + Respawner, + "_ensure_archive_consistent", + side_effect=QQError("inconsistent"), + ), + pytest.raises(QQError, match="inconsistent"), + ): + respawner_with_info_file._build_submitter(informer) + + +def test_respawner_build_submitter_passes_loop_info_to_submitter( + respawner_with_info_file, +): + informer = MagicMock() + loop_info = MagicMock() + informer.info.loop_info = loop_info + + with ( + patch("qq_lib.respawn.respawner.Submitter") as mock_submitter_cls, + patch("qq_lib.respawn.respawner.filter_dependencies", return_value=[]), + patch.object(Respawner, "_ensure_archive_consistent"), + ): + respawner_with_info_file._build_submitter(informer) + + assert mock_submitter_cls.call_args.kwargs["loop_info"] is loop_info + + +RESPAWNABLE_STATES = {RealState.FAILED, RealState.KILLED} + + +@pytest.mark.parametrize("state", RealState) +def test_respawner_ensure_suitable(state): + respawner = Respawner.__new__(Respawner) + respawner._state = state + + if state in RESPAWNABLE_STATES: + respawner.ensure_suitable() + else: + with pytest.raises(QQNotSuitableError, match="cannot be respawned"): + respawner.ensure_suitable() + + +@pytest.fixture +def respawner_for_respawn(): + respawner = Respawner.__new__(Respawner) + respawner._info_file = Path("/tmp/input/job.qqinfo") + respawner.get_informer = MagicMock() + respawner._build_submitter = MagicMock() + respawner._build_submitter.return_value.submit.return_value = "99999" + return respawner + + +def test_respawnerrespawn_returns_job_id(respawner_for_respawn): + with ( + patch("qq_lib.respawn.respawner.Wiper"), + patch("qq_lib.respawn.respawner.Clearer"), + ): + result = respawner_for_respawn.respawn() + + assert result == "99999" + + +def test_respawner_respawn_wipes_and_clears_before_submitting(respawner_for_respawn): + call_order = [] + + with ( + patch("qq_lib.respawn.respawner.Wiper") as mock_wiper_cls, + patch("qq_lib.respawn.respawner.Clearer") as mock_clearer_cls, + ): + wiper = mock_wiper_cls.from_informer.return_value + wiper.wipe.side_effect = lambda: call_order.append("wipe") + mock_clearer_cls.return_value.clear.side_effect = lambda: call_order.append( + "clear" + ) + respawner_for_respawn._build_submitter.return_value.submit.side_effect = ( + lambda: (call_order.append("submit"), "99999")[1] + ) + + respawner_for_respawn.respawn() + + assert call_order == ["wipe", "clear", "submit"] + + +def test_respawnerrespawn_calls_wiper_from_informer(respawner_for_respawn): + informer = respawner_for_respawn.get_informer.return_value + + with ( + patch("qq_lib.respawn.respawner.Wiper") as mock_wiper_cls, + patch("qq_lib.respawn.respawner.Clearer"), + ): + respawner_for_respawn.respawn() + + mock_wiper_cls.from_informer.assert_called_once_with(informer) + + +def test_respawner_respawn_calls_clearer_with_input_dir(respawner_for_respawn): + with ( + patch("qq_lib.respawn.respawner.Wiper"), + patch("qq_lib.respawn.respawner.Clearer") as mock_clearer_cls, + ): + respawner_for_respawn.respawn() + + mock_clearer_cls.assert_called_once_with(Path("/tmp/input")) + mock_clearer_cls.return_value.clear.assert_called_once() + + +def test_respawner_respawn_continues_when_wiper_not_suitable(respawner_for_respawn): + with ( + patch("qq_lib.respawn.respawner.Wiper") as mock_wiper_cls, + patch("qq_lib.respawn.respawner.Clearer") as mock_clearer_cls, + ): + wiper = mock_wiper_cls.from_informer.return_value + wiper.ensure_suitable.side_effect = QQNotSuitableError("not suitable") + + result = respawner_for_respawn.respawn() + + assert result == "99999" + wiper.wipe.assert_not_called() + mock_clearer_cls.return_value.clear.assert_called_once() + + +def test_respawner_respawn_continues_when_wipe_fails_with_qq_error( + respawner_for_respawn, +): + with ( + patch("qq_lib.respawn.respawner.Wiper") as mock_wiper_cls, + patch("qq_lib.respawn.respawner.Clearer") as mock_clearer_cls, + patch("qq_lib.respawn.respawner.logger") as mock_logger, + ): + wiper = mock_wiper_cls.from_informer.return_value + wiper.wipe.side_effect = QQError("permission denied") + + result = respawner_for_respawn.respawn() + + assert result == "99999" + mock_logger.warning.assert_any_call( + "Failed to remove working directory: permission denied" + ) + mock_clearer_cls.return_value.clear.assert_called_once() + + +def test_respawner_respawn_builds_submitter_from_informer(respawner_for_respawn): + informer = respawner_for_respawn.get_informer.return_value + + with ( + patch("qq_lib.respawn.respawner.Wiper"), + patch("qq_lib.respawn.respawner.Clearer"), + ): + respawner_for_respawn.respawn() + + respawner_for_respawn._build_submitter.assert_called_once_with(informer) From f3d9e03025677f177f9931725fe485fc2ccc39c5 Mon Sep 17 00:00:00 2001 From: Ladme Date: Sat, 9 May 2026 10:16:58 +0200 Subject: [PATCH 31/43] Docs for respawn and resubmit --- src/qq_lib/respawn/__init__.py | 12 ++++++++++++ src/qq_lib/run/__init__.py | 7 ++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/qq_lib/respawn/__init__.py b/src/qq_lib/respawn/__init__.py index d24db1e..db648c8 100644 --- a/src/qq_lib/respawn/__init__.py +++ b/src/qq_lib/respawn/__init__.py @@ -1,2 +1,14 @@ # Released under MIT License. # Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab + +""" +Respawn utilities for qq jobs. + +This module defines the `Respawner` class, which extends `Operator` to validate +whether a failed or killed job can be respawned and to resubmit it with its +original parameters. + +Respawning involves cleaning up the working directory, clearing generated files +from the input directory, and submitting a fresh copy of the job. For loop jobs, +the archive directory is checked for consistency before resubmission. +""" diff --git a/src/qq_lib/run/__init__.py b/src/qq_lib/run/__init__.py index 4e38f9c..f0e7d10 100644 --- a/src/qq_lib/run/__init__.py +++ b/src/qq_lib/run/__init__.py @@ -7,12 +7,17 @@ This module defines the `Runner` class, which prepares the execution environment, launches the user's job script, updates qq's state tracking, and performs cleanup on success, failure, or interruption. It handles both -shared and scratch working directories, loop-job archiving, resubmiting, +shared and scratch working directories, loop-job archiving, resubmission, communication with the batch system, and SIGTERM-safe shutdown. + +It also defines the `Resubmitter` class, which handles resubmission of loop +and continuous jobs by resolving candidate hosts and attempting submission +on each in order until one succeeds. """ from .runner import Runner __all__ = [ "Runner", + "Resubmitter", ] From 10c6ef2cd31b1b3fdeaabe224a016547ae8ea4af Mon Sep 17 00:00:00 2001 From: Ladme Date: Sat, 9 May 2026 13:16:07 +0200 Subject: [PATCH 32/43] Refactored qq go, info, kill, respawn, sync, wipe The aforementioned commands now support multiple job IDs on the command line and resolve jobs in parallel --- src/qq_lib/core/command_runner.py | 253 ++++++++++++++++ src/qq_lib/core/config.py | 11 + src/qq_lib/core/error_handlers.py | 57 ++-- src/qq_lib/go/cli.py | 51 +--- src/qq_lib/info/cli.py | 49 +--- src/qq_lib/info/informer.py | 7 + src/qq_lib/kill/cli.py | 52 ++-- src/qq_lib/killall/cli.py | 2 +- src/qq_lib/respawn/cli.py | 54 ++-- src/qq_lib/sync/cli.py | 53 ++-- src/qq_lib/wipe/cli.py | 50 ++-- tests/test_core_command_runner.py | 470 ++++++++++++++++++++++++++++++ tests/test_core_error_handlers.py | 36 ++- tests/test_go_cli.py | 101 +++---- tests/test_info_cli.py | 96 ++---- tests/test_kill_cli.py | 108 +++---- tests/test_respawn_cli.py | 102 +++---- tests/test_sync_cli.py | 100 +++---- tests/test_wipe_cli.py | 104 +++---- 19 files changed, 1100 insertions(+), 656 deletions(-) create mode 100644 src/qq_lib/core/command_runner.py create mode 100644 tests/test_core_command_runner.py diff --git a/src/qq_lib/core/command_runner.py b/src/qq_lib/core/command_runner.py new file mode 100644 index 0000000..5a288bd --- /dev/null +++ b/src/qq_lib/core/command_runner.py @@ -0,0 +1,253 @@ +# Released under MIT License. +# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab + + +import logging +import sys +import threading +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Any, NoReturn, Self + +from qq_lib.core.common import get_info_files +from qq_lib.core.config import CFG +from qq_lib.core.error import QQError +from qq_lib.info import Informer + + +class CommandRunner: + """ + Runs a job operation against one or more qq jobs. + + Resolves and prepares informers in parallel using a thread pool, then + executes the callback serially on the main thread in the original order, + starting as soon as the next-in-order preparation completes. + + All exceptions are caught internally and converted to `sys.exit` calls. + Specific exception types can be handled gracefully via `on_exception`. + + Attributes: + n_jobs (int): The total number of jobs to run. + encountered_errors (dict[int, Exception]): A dictionary mapping + job indices to exceptions encountered during preparation or execution. + """ + + def __init__( + self, + jobs: tuple[str, ...], + 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. + 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._callback = callback + self._args = args + self._kwargs = kwargs + self._exception_handlers: dict[type[Exception], Callable] = {} + + self.n_jobs = 0 + self.current_iteration = 0 + self.encountered_errors: dict[int, Exception] = {} + + def on_exception(self, exc_type: type[Exception], handler: Callable) -> Self: + """ + Register an exception handler for a specific exception type. + + Registered handlers are invoked when the callback or the preparation + step raises the given exception type. Unregistered exception types + propagate up and cause the process to exit. + + Args: + exc_type (type[Exception]): The exception type to handle. + handler (Callable): Function to call when `exc_type` is raised. + Must accept two arguments: the exception instance and + a reference to this `CommandRunner`. + + Returns: + Self for chaining. + """ + self._exception_handlers[exc_type] = handler + return self + + def run(self) -> NoReturn: + """ + Resolve all jobs, execute the callback for each, and exit the process. + + Resolves informers from job IDs or info files in the target directory, + prepares them in parallel, and executes the registered callback for each + job in the original order. Registered exception handlers are invoked for + known error types; all other exceptions cause the process to exit. + + This method never returns. It always terminates with `sys.exit`: + - Exit code 0 on success. + - Exit code `CFG.exit_codes.default` on `QQError`. + - Exit code `CFG.exit_codes.unexpected_error` on any other exception. + """ + try: + targets = self._build_targets() + self._run_pipeline(targets) + sys.exit(0) + except QQError as e: + self._logger.error(e) + sys.exit(CFG.exit_codes.default) + except Exception as e: + self._logger.critical(e, exc_info=True, stack_info=True) + sys.exit(CFG.exit_codes.unexpected_error) + + def _build_targets(self) -> list[Callable[[], Informer]]: + """ + Build a list of callables that each resolve and prepare one Informer. + + If job IDs were provided, each target resolves via `Informer.from_job_id`. + Otherwise, the specified (or current) directory is searched for qq info files + and each target resolves via `Informer.from_file`. + + Each target then also reloads the informer's batch info. + + Returns: + list[Callable[[], Informer]]: One callable per job. + + Raises: + QQError: If no job IDs were given and no info files were found in the current directory. + """ + 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 + + 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: + 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)) + ) + + return targets + + def _run_pipeline(self, targets: list[Callable[[], Informer]]) -> None: + """ + Run the prepare-then-execute pipeline. + + Submits all targets to a thread pool for parallel preparation. + The main thread waits on a condition variable and executes each + job's callback as soon as it is the next in order, ensuring output + and side effects follow the original job order. + + Failed preparations and executions are passed to `_handle_error`. + + Args: + targets: List of callables that each resolve and prepare one Informer. + """ + self.n_jobs = len(targets) + results: list[Informer | Exception | None] = [None] * self.n_jobs + lock = threading.Lock() + ready = threading.Condition(lock) + + def prepare(index: int, target: Callable[[], Informer]) -> None: + """ + Execute a single target and store its result or exception. + + Notifies the main thread upon completion so it can check whether + the next-in-order result is available. + + Args: + index (int): The position of this target in the original order. + target (Callable[[], Informer]): The callable to execute. + """ + try: + result: Informer | Exception = target() + except Exception as e: + result = e + + with ready: + results[index] = result + ready.notify() + + with ThreadPoolExecutor(max_workers=self._n_threads) as executor: + # submit all preparation tasks to the thread pool + for i, target in enumerate(targets): + executor.submit(prepare, i, target) + + # process results in order on the main thread + next_index = 0 + with ready: + while next_index < self.n_jobs: + # block the main thread until the next result is available + # later jobs may finish first, but we wait for the one we need + while results[next_index] is None: + ready.wait() + + self.current_iteration += 1 + result = results[next_index] + next_index += 1 + + # handle error or execute the callback + if isinstance(result, Exception): + self._handle_error(result) + elif isinstance(result, Informer): + self._execute(result) + else: + raise ValueError( + f"Unexpected result type: {type(result)}. This is a bug, please repport it." + ) + + def _execute(self, informer: Informer) -> None: + """ + Run the callback on a prepared informer. + + Args: + informer (Informer): A resolved and prepared Informer. + """ + try: + self._callback(informer, *self._args, **self._kwargs) + except tuple(self._exception_handlers.keys()) as e: + self._handle_error(e) + + def _handle_error(self, error: Exception) -> None: + """ + Handle an exception using registered handlers, or re-raise. + + Args: + error (Exception): The exception to handle. + + Raises: + Exception: If no handler is registered for the exception type. + """ + self.encountered_errors[self.current_iteration] = error + handler = self._exception_handlers.get(type(error)) + if handler: + handler(error, self) + else: + raise error diff --git a/src/qq_lib/core/config.py b/src/qq_lib/core/config.py index abb619d..98bac9c 100644 --- a/src/qq_lib/core/config.py +++ b/src/qq_lib/core/config.py @@ -463,6 +463,14 @@ class BatchServersOptions: ) +@dataclass(frozen=True) +class ParallelizationOptions: + """Options associated with multithreaded execution.""" + + # Maximal number of threads used to collect job information. + job_info_max_threads: int = 8 + + @dataclass(frozen=True) class Config: """Main configuration for qq.""" @@ -497,6 +505,9 @@ class Config: batch_servers_options: BatchServersOptions = field( default_factory=BatchServersOptions ) + parallelization_options: ParallelizationOptions = field( + default_factory=ParallelizationOptions + ) # Name of the qq binary. binary_name: str = "qq" diff --git a/src/qq_lib/core/error_handlers.py b/src/qq_lib/core/error_handlers.py index ea0eba6..e902a5f 100644 --- a/src/qq_lib/core/error_handlers.py +++ b/src/qq_lib/core/error_handlers.py @@ -13,70 +13,61 @@ import sys from typing import NoReturn +from qq_lib.core.command_runner import CommandRunner + from .config import CFG from .error import QQNotSuitableError from .logger import get_logger -from .repeater import Repeater logger = get_logger(__name__) def handle_not_suitable_error( - exception: BaseException, - metadata: Repeater, + exception: Exception, + runner: CommandRunner, ) -> None: - """ - Handle cases where a job is unsuitable for a qq operation. - """ - # if this is the only item, print exception as an error - if len(metadata.items) == 1: + """Handle cases where a job is unsuitable for a qq operation.""" + if runner.n_jobs == 1: logger.error(exception) - print() sys.exit(CFG.exit_codes.default) - # if this is one of many items, print exception as info - if len(metadata.items) > 1: + if runner.n_jobs > 1: logger.info(exception) - # if all jobs were unsuitable - if sum( - isinstance(x, QQNotSuitableError) for x in metadata.encountered_errors.values() - ) == len(metadata.items): + if ( + sum( + isinstance(x, QQNotSuitableError) + for x in runner.encountered_errors.values() + ) + == runner.n_jobs + ): logger.error("No suitable qq job.\n") sys.exit(CFG.exit_codes.default) def handle_job_mismatch_error( - exception: BaseException, - _metadata: Repeater, + exception: Exception, + _runner: CommandRunner, ) -> NoReturn: - """ - Handle cases where the provided job ID does not match the qq info file. - """ + """Handle cases where the provided job ID does not match the qq info file.""" logger.error(exception) sys.exit(CFG.exit_codes.default) def handle_general_qq_error( - exception: BaseException, - metadata: Repeater, + exception: Exception, + runner: CommandRunner, ) -> None: - """ - Handle general qq errors that occur during a qq operation. - """ + """Handle general qq errors that occur during a qq operation.""" logger.error(exception) - # if the operation failed for all items - if len(metadata.items) == len(metadata.encountered_errors): - print() + if runner.n_jobs == len(runner.encountered_errors): sys.exit(CFG.exit_codes.default) def ignore_error( - _exception: BaseException, - _metadata: Repeater, + _exception: Exception, + _runner: CommandRunner, ) -> None: - """ - Ignore the error. - """ + """Ignore the error.""" pass diff --git a/src/qq_lib/go/cli.py b/src/qq_lib/go/cli.py index 0020015..b332377 100644 --- a/src/qq_lib/go/cli.py +++ b/src/qq_lib/go/cli.py @@ -1,17 +1,13 @@ # Released under MIT License. # Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab -import sys -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.common import ( - get_info_files, -) +from qq_lib.core.command_runner import CommandRunner from qq_lib.core.config import CFG from qq_lib.core.error import ( QQError, @@ -22,7 +18,6 @@ handle_not_suitable_error, ) from qq_lib.core.logger import get_logger -from qq_lib.core.repeater import Repeater from qq_lib.go.goer import Goer from qq_lib.info.informer import Informer @@ -35,10 +30,10 @@ 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. -{click.style("JOB_ID", fg="green")} The identifier of the job whose working directory should be entered. Optional. +{click.style("JOB_ID", fg="green")} One or more IDs of jobs whose working directories should be entered. Optional. -If JOB_ID is not specified, `{CFG.binary_name} go` searches for qq jobs in the current directory. -If multiple suitable jobs are found, `{CFG.binary_name} go` opens a shell for each job in turn. +If no JOB_ID is 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. 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; @@ -48,39 +43,25 @@ help_options_color="bright_blue", ) @click.argument( - "job", + "jobs", type=str, metavar=click.style("JOB_ID", fg="green"), required=False, default=None, + nargs=-1, ) -def go(job: str | None) -> NoReturn: +def go(jobs: tuple[str, ...]) -> NoReturn: """ - Go to the working directory (directories) of the specified qq job 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 this directory. """ - try: - if job: - informers = [Informer.from_job_id(job)] - else: - if not ( - informers := [ - Informer.from_file(info) for info in get_info_files(Path.cwd()) - ] - ): - raise QQError("No qq job info file found.") - - repeater = Repeater(informers, _go_to_job) - repeater.on_exception(QQNotSuitableError, handle_not_suitable_error) - repeater.on_exception(QQError, handle_general_qq_error) - repeater.run() - print() - 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) + CommandRunner( + jobs, + _go_to_job, + logger, + n_threads=CFG.parallelization_options.job_info_max_threads, + ).on_exception(QQNotSuitableError, handle_not_suitable_error).on_exception( + QQError, handle_general_qq_error + ).run() def _go_to_job(informer: Informer) -> None: diff --git a/src/qq_lib/info/cli.py b/src/qq_lib/info/cli.py index e0934c5..a968db5 100644 --- a/src/qq_lib/info/cli.py +++ b/src/qq_lib/info/cli.py @@ -1,21 +1,17 @@ # Released under MIT License. # Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab -import sys -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.common import ( - get_info_files, -) +from qq_lib.core.command_runner import CommandRunner from qq_lib.core.config import CFG from qq_lib.core.error import QQError +from qq_lib.core.error_handlers import handle_general_qq_error 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.info.presenter import Presenter @@ -24,48 +20,35 @@ @click.command( short_help="Display information about a job.", - help=f"""Display information about the state and properties of the specified qq job, + help=f"""Display information about the state and properties of the specified qq jobs, or of qq jobs found in the current directory. -{click.style("JOB_ID", fg="green")} The identifier of the job to display information for. Optional. +{click.style("JOB_ID", fg="green")} One or more IDs of jobs to display information for. Optional. -If JOB_ID is not specified, `{CFG.binary_name} info` searches for qq jobs in the current directory.""", +If no JOB_ID is specified, `{CFG.binary_name} info` searches for qq jobs in the current directory.""", cls=GNUHelpColorsCommand, help_options_color="bright_blue", ) @click.argument( - "job", + "jobs", type=str, metavar=click.style("JOB_ID", fg="green"), - required=False, - default=None, + nargs=-1, ) @click.option( "-s", "--short", is_flag=True, help="Display only the job ID and current state." ) -def info(job: str | None, short: bool) -> NoReturn: +def info(jobs: tuple[str, ...], short: bool) -> NoReturn: """ - Get information about the specified qq job or qq job(s) submitted from this directory. + Get information about the specified qq jobs or qq jobs submitted from this directory. """ - try: - if job: - informers = [Informer.from_job_id(job)] - else: - if not ( - informers := [ - Informer.from_file(info) for info in get_info_files(Path.cwd()) - ] - ): - raise QQError("No qq job info file found.") - - Repeater(informers, _info_for_job, short).run() - 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) + CommandRunner( + jobs, + _info_for_job, + logger, + short, + 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: diff --git a/src/qq_lib/info/informer.py b/src/qq_lib/info/informer.py index 9ed9f96..0c90f7f 100644 --- a/src/qq_lib/info/informer.py +++ b/src/qq_lib/info/informer.py @@ -219,6 +219,13 @@ def get_destination(self) -> tuple[str, Path] | None: return main_node, work_dir return None + def load_batch_info(self) -> None: + """ + Load the batch job information from the batch system if it's not already loaded. + """ + if self._batch_info is None: + self._batch_info = self.batch_system.get_batch_job(self.info.job_id) + def get_batch_state(self) -> BatchState: """ Return the job's state as reported by the batch system. diff --git a/src/qq_lib/kill/cli.py b/src/qq_lib/kill/cli.py index 523c758..fcf0680 100644 --- a/src/qq_lib/kill/cli.py +++ b/src/qq_lib/kill/cli.py @@ -1,16 +1,14 @@ # Released under MIT License. # Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab -import sys -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.command_runner import CommandRunner from qq_lib.core.common import ( - get_info_files, yes_or_no_prompt, ) from qq_lib.core.config import CFG @@ -23,7 +21,6 @@ handle_not_suitable_error, ) 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.killer import Killer @@ -33,11 +30,11 @@ @click.command( short_help="Terminate a job.", - help=f"""Terminate the specified qq job, or all qq jobs in the current directory. + help=f"""Terminate the specified qq jobs, or all qq jobs in the current directory. -{click.style("JOB_ID", fg="green")} The identifier of the job to terminate. Optional. +{click.style("JOB_ID", fg="green")} One or more IDs of jobs to terminate. Optional. -If JOB_ID is not specified, `{CFG.binary_name} kill` searches for qq jobs in the current directory. +If no JOB_ID is specified, `{CFG.binary_name} kill` searches for qq jobs in the current directory. By default, `{CFG.binary_name} kill` prompts for confirmation before terminating a job. @@ -50,11 +47,12 @@ help_options_color="bright_blue", ) @click.argument( - "job", + "jobs", type=str, metavar=click.style("JOB_ID", fg="green"), required=False, default=None, + nargs=-1, ) @click.option( "-y", "--yes", is_flag=True, help="Terminate the job without confirmation." @@ -64,9 +62,9 @@ is_flag=True, help="Terminate the job forcibly, ignoring its current state and without confirmation.", ) -def kill(job: str | None, yes: bool = False, force: bool = False) -> NoReturn: +def kill(jobs: tuple[str, ...], yes: bool = False, force: bool = False) -> NoReturn: """ - Terminate the specified qq job or qq job(s) submitted from the current directory. + Terminate the specified qq job(s) or qq job(s) submitted from the current directory. Details Killing a job sets its state to "killed". This is handled either by `qq kill` or @@ -88,30 +86,16 @@ def kill(job: str | None, yes: bool = False, force: bool = False) -> NoReturn: - Normal (non-forced) termination: `qq run` is responsible for updating the job state in the info file once the job is terminated. """ - try: - if job: - informers = [Informer.from_job_id(job)] - else: - if not ( - informers := [ - Informer.from_file(info) for info in get_info_files(Path.cwd()) - ] - ): - raise QQError("No qq job info file found.") - - repeater = Repeater(informers, kill_job, force, yes) - repeater.on_exception(QQNotSuitableError, handle_not_suitable_error) - repeater.on_exception(QQError, handle_general_qq_error) - repeater.run() - print() - sys.exit(0) - # QQErrors should be caught by Repeater - 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) + CommandRunner( + jobs, + kill_job, + logger, + force, + yes, + n_threads=CFG.parallelization_options.job_info_max_threads, + ).on_exception(QQNotSuitableError, handle_not_suitable_error).on_exception( + QQError, handle_general_qq_error + ).run() def kill_job(informer: Informer, force: bool, yes: bool) -> None: diff --git a/src/qq_lib/killall/cli.py b/src/qq_lib/killall/cli.py index 1a87c19..3e55113 100644 --- a/src/qq_lib/killall/cli.py +++ b/src/qq_lib/killall/cli.py @@ -73,6 +73,7 @@ def killall( 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, @@ -86,7 +87,6 @@ def killall( logger.info("Operation aborted.") sys.exit(0) - # QQErrors should be caught by Repeater except QQError as e: logger.error(e) sys.exit(CFG.exit_codes.default) diff --git a/src/qq_lib/respawn/cli.py b/src/qq_lib/respawn/cli.py index d440233..c77fa2c 100644 --- a/src/qq_lib/respawn/cli.py +++ b/src/qq_lib/respawn/cli.py @@ -1,15 +1,13 @@ # Released under MIT License. # Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab -import sys -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.common import get_info_files +from qq_lib.core.command_runner import CommandRunner from qq_lib.core.config import CFG from qq_lib.core.error import QQError, QQNotSuitableError from qq_lib.core.error_handlers import ( @@ -17,7 +15,6 @@ handle_not_suitable_error, ) from qq_lib.core.logger import get_logger -from qq_lib.core.repeater import Repeater from qq_lib.info import Informer from qq_lib.respawn.respawner import Respawner @@ -27,11 +24,11 @@ @click.command( short_help="Respawn a failed/killed job.", - help=f"""Respawn the specified qq job, or all qq jobs in the current directory. + help=f"""Respawn the specified qq jobs, or all qq jobs in the current directory. -{click.style("JOB_ID", fg="green")} The identifier of the job to respawn. Optional. +{click.style("JOB_ID", fg="green")} One or more IDs of jobs to respawn. Optional. -If JOB_ID is not specified, `{CFG.binary_name} respawn` searches for qq jobs in the current directory. +If no JOB_ID is specified, `{CFG.binary_name} respawn` searches for qq jobs in the current directory. 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, @@ -40,40 +37,25 @@ help_options_color="bright_blue", ) @click.argument( - "job", + "jobs", type=str, metavar=click.style("JOB_ID", fg="green"), required=False, default=None, + nargs=-1, ) -def respawn(job: str | None) -> NoReturn: - try: - if job: - informers = [Informer.from_job_id(job)] - else: - if not ( - informers := [ - Informer.from_file(info) for info in get_info_files(Path.cwd()) - ] - ): - raise QQError("No qq job info file found.") - - repeater = Repeater(informers, respawn_job) - repeater.on_exception(QQNotSuitableError, handle_not_suitable_error) - repeater.on_exception(QQError, handle_general_qq_error) - repeater.run() - print() - sys.exit(0) - # QQErrors should be caught by Repeater - 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 respawn_job(informer: Informer) -> None: +def respawn(jobs: tuple[str, ...]) -> NoReturn: + CommandRunner( + jobs, + _respawn_job, + logger, + n_threads=CFG.parallelization_options.job_info_max_threads, + ).on_exception(QQNotSuitableError, handle_not_suitable_error).on_exception( + QQError, handle_general_qq_error + ).run() + + +def _respawn_job(informer: Informer) -> None: """ Attempt to respawn a qq job associated with the specified informer. diff --git a/src/qq_lib/sync/cli.py b/src/qq_lib/sync/cli.py index 7d35661..76d5297 100644 --- a/src/qq_lib/sync/cli.py +++ b/src/qq_lib/sync/cli.py @@ -2,15 +2,13 @@ # Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab import re -import sys -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.common import get_info_files +from qq_lib.core.command_runner import CommandRunner from qq_lib.core.config import CFG from qq_lib.core.error import ( QQError, @@ -21,7 +19,6 @@ handle_not_suitable_error, ) from qq_lib.core.logger import get_logger -from qq_lib.core.repeater import Repeater from qq_lib.info.informer import Informer from .syncer import Syncer @@ -35,10 +32,10 @@ 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. -{click.style("JOB_ID", fg="green")} The identifier of the job whose working directory files should be fetched. Optional. +{click.style("JOB_ID", fg="green")} One or more IDs of jobs whose working directory files should be fetched. Optional. -If JOB_ID is not specified, `{CFG.binary_name} sync` searches for qq jobs in the current directory. -If multiple suitable jobs are found, `{CFG.binary_name} sync` fetches files from each job in turn. +If no JOB_ID is 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. Files are copied from the job's working directory to its input directory, not to the current directory. @@ -47,11 +44,12 @@ help_options_color="bright_blue", ) @click.argument( - "job", + "jobs", type=str, metavar=click.style("JOB_ID", fg="green"), required=False, default=None, + nargs=-1, ) @click.option( "-f", @@ -61,35 +59,20 @@ 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(job: str | None, files: str | None) -> NoReturn: +def sync(jobs: tuple[str, ...], files: str | None) -> NoReturn: """ - Fetch files from the working directory of the specified qq job or - working directory (directories) of qq job(s) submitted from this directory. + Fetch files from the working directory (directories) of the specified qq job(s) + or of qq job(s) submitted from this directory. """ - try: - if job: - informers = [Informer.from_job_id(job)] - else: - if not ( - informers := [ - Informer.from_file(info) for info in get_info_files(Path.cwd()) - ] - ): - raise QQError("No qq job info file found.") - - repeater = Repeater(informers, _sync_job, _split_files(files)) - repeater.on_exception(QQNotSuitableError, handle_not_suitable_error) - repeater.on_exception(QQError, handle_general_qq_error) - repeater.run() - print() - sys.exit(0) - # QQErrors should be caught by Repeater - 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) + CommandRunner( + jobs, + _sync_job, + logger, + _split_files(files), + n_threads=CFG.parallelization_options.job_info_max_threads, + ).on_exception(QQNotSuitableError, handle_not_suitable_error).on_exception( + QQError, handle_general_qq_error + ).run() def _split_files(files: str | None) -> list[str] | None: diff --git a/src/qq_lib/wipe/cli.py b/src/qq_lib/wipe/cli.py index a2e83b1..852437b 100644 --- a/src/qq_lib/wipe/cli.py +++ b/src/qq_lib/wipe/cli.py @@ -1,16 +1,14 @@ # Released under MIT License. # Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab -import sys -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.command_runner import CommandRunner from qq_lib.core.common import ( - get_info_files, yes_or_no_prompt, ) from qq_lib.core.config import CFG @@ -23,7 +21,6 @@ handle_not_suitable_error, ) 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.wipe.wiper import Wiper @@ -33,11 +30,11 @@ @click.command( short_help="Delete the working directory of a job.", - help=f"""Delete the working directory of the specified qq job, 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 current directory. -{click.style("JOB_ID", fg="green")} The identifier of the job which working directory should be deleted. Optional. +{click.style("JOB_ID", fg="green")} One or more IDs of jobs whose working directories should be deleted. Optional. -If JOB_ID is not specified, `{CFG.binary_name} wipe` searches for qq jobs in the current directory. +If no JOB_ID is specified, `{CFG.binary_name} wipe` searches for qq jobs in the current directory. By default, `{CFG.binary_name} wipe` prompts for confirmation before deleting the working directory. @@ -51,11 +48,12 @@ help_options_color="bright_blue", ) @click.argument( - "job", + "jobs", type=str, metavar=click.style("JOB_ID", fg="green"), required=False, default=None, + nargs=-1, ) @click.option( "-y", @@ -68,34 +66,20 @@ is_flag=True, help="Delete the working directory of the job forcibly, ignoring its current state and without confirmation.", ) -def wipe(job: str | None, yes: bool = False, force: bool = False) -> NoReturn: +def wipe(jobs: tuple[str, ...], 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. """ - try: - if job: - informers = [Informer.from_job_id(job)] - else: - if not ( - informers := [ - Informer.from_file(info) for info in get_info_files(Path.cwd()) - ] - ): - raise QQError("No qq job info file found.") - - repeater = Repeater(informers, _wipe_work_dir, force, yes) - repeater.on_exception(QQNotSuitableError, handle_not_suitable_error) - repeater.on_exception(QQError, handle_general_qq_error) - repeater.run() - print() - sys.exit(0) - # QQErrors should be caught by Repeater - 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) + CommandRunner( + jobs, + _wipe_work_dir, + logger, + force, + yes, + n_threads=CFG.parallelization_options.job_info_max_threads, + ).on_exception(QQNotSuitableError, handle_not_suitable_error).on_exception( + QQError, handle_general_qq_error + ).run() def _wipe_work_dir(informer: Informer, force: bool, yes: bool) -> None: diff --git a/tests/test_core_command_runner.py b/tests/test_core_command_runner.py new file mode 100644 index 0000000..2242c1a --- /dev/null +++ b/tests/test_core_command_runner.py @@ -0,0 +1,470 @@ +# Released under MIT License. +# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab + + +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock, patch + +import pytest + +from qq_lib.core.command_runner import CommandRunner +from qq_lib.core.config import CFG +from qq_lib.core.error import QQError, QQNotSuitableError +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 test_command_runner_build_targets_from_job_ids(): + runner = CommandRunner(("111", "222"), lambda _: None, MagicMock(), n_threads=1) + + 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}" + ) + targets = runner._build_targets() + + assert len(targets) == 2 + + +def test_command_runner_build_targets_from_info_files(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.get_info_files", return_value=[info1, info2]), + 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(): + runner = CommandRunner((), lambda _: None, MagicMock(), n_threads=1) + + 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_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() + targets = runner._build_targets() + targets[0]() + + mock_informer.from_job_id.assert_called_once_with("12345") + + +def test_command_runner_build_targets_resolves_informer_from_file(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.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() + targets = runner._build_targets() + targets[0]() + + mock_informer.from_file.assert_called_once_with(info_file) + + +def test_command_runner_build_targets_calls_load_batch_info(): + runner = CommandRunner(("111",), lambda _: None, MagicMock(), n_threads=1) + + 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]() + + informer.load_batch_info.assert_called_once() + + +def test_command_runner_build_targets_uses_specified_directory(tmp_path): + runner = CommandRunner( + (), lambda _: None, MagicMock(), n_threads=1, directory=tmp_path + ) + + with ( + patch("qq_lib.core.command_runner.get_info_files", return_value=[]) as mock_get, + pytest.raises(QQError), + ): + runner._build_targets() + + mock_get.assert_called_once_with(tmp_path) + + +def test_command_runner_execute_calls_callback_with_args(): + callback = MagicMock() + runner = CommandRunner( + ("111",), callback, MagicMock(), "arg1", "arg2", n_threads=1, kw="val" + ) + + informer = MagicMock() + 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) + + with pytest.raises(RuntimeError, match="boom"): + runner._execute(MagicMock()) + + +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) + + runner._execute(MagicMock()) + + handler.assert_called_once() + assert isinstance(handler.call_args[0][0], QQError) + assert handler.call_args[0][1] is runner + + +def test_command_runner_handle_error_records_in_encountered_errors(): + runner = CommandRunner(("111",), lambda _: None, MagicMock(), n_threads=1) + runner.current_iteration = 3 + error = QQError("fail") + + runner.on_exception(QQError, MagicMock()) + runner._handle_error(error) + + assert runner.encountered_errors[3] is error + + +def test_command_runner_handle_error_calls_registered_handler(): + 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) + error = RuntimeError("unexpected") + + with pytest.raises(RuntimeError, match="unexpected"): + runner._handle_error(error) + + assert runner.encountered_errors[0] is error + + +def test_command_runner_on_exception_returns_self_for_chaining(): + runner = CommandRunner(("111",), lambda _: None, MagicMock(), n_threads=1) + + result = runner.on_exception(QQError, MagicMock()) + + assert result is runner + + +def test_command_runner_on_exception_registers_multiple_handlers(): + runner = CommandRunner(("111",), lambda _: None, MagicMock(), n_threads=1) + handler1 = MagicMock() + handler2 = MagicMock() + + runner.on_exception(QQError, handler1).on_exception(QQNotSuitableError, handler2) + + runner.current_iteration = 0 + runner._handle_error(QQError("e1")) + handler1.assert_called_once() + + runner._handle_error(QQNotSuitableError("e2")) + handler2.assert_called_once() + + +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) + + informer1 = MagicMock(spec=Informer) + informer2 = MagicMock(spec=Informer) + targets: list[Callable[[], Informer]] = [lambda: informer1, lambda: informer2] + + runner._run_pipeline(targets) + + 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 + + def fast_target(): + informer = MagicMock(spec=Informer) + informer.name = "fast" + return informer + + def callback(i, *a: Any, **kw: Any): + _ = a, kw + return execution_order.append(i.name) + + runner = CommandRunner(("111", "222"), callback, MagicMock(), n_threads=2) + runner._run_pipeline([slow_target, fast_target]) + + assert execution_order == ["slow", "fast"] + + +def test_command_runner_run_pipeline_handles_preparation_failure(): + handler = MagicMock() + callback = MagicMock() + runner = CommandRunner(("111",), callback, MagicMock(), n_threads=1) + runner.on_exception(QQError, handler) + + def failing_target(): + raise QQError("resolve failed") + + runner._run_pipeline([failing_target]) + + handler.assert_called_once() + callback.assert_not_called() + + +def test_command_runner_run_pipeline_continues_after_preparation_failure(): + results = [] + handler = MagicMock() + + def callback(i, *a, **kw): + _ = a, kw + return results.append(i) + + runner = CommandRunner(("111", "222"), callback, MagicMock(), n_threads=1) + runner.on_exception(QQError, handler) + + informer = MagicMock(spec=Informer) + + def failing_target(): + raise QQError("resolve failed") + + runner._run_pipeline([failing_target, lambda: informer]) + + handler.assert_called_once() + assert results == [informer] + + +def test_command_runner_run_pipeline_continues_after_callback_failure(): + call_count = 0 + + def callback(informer: Informer, *args: Any, **kwargs: Any): + _ = informer, args, kwargs + nonlocal call_count + call_count += 1 + if call_count == 1: + 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) + + runner._run_pipeline([make_informer, make_informer]) + + handler.assert_called_once() + assert call_count == 2 + + +def test_command_runner_run_pipeline_sets_n_jobs(): + def make_informer(): + return MagicMock(spec=Informer) + + 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 + + +def test_command_runner_run_pipeline_tracks_current_iteration(): + iterations = [] + + def handler(e: Exception, r: CommandRunner): + _ = e + return iterations.append(r.current_iteration) + + runner = CommandRunner(("1", "2", "3"), lambda _: None, MagicMock(), n_threads=1) + runner.on_exception(QQError, handler) + + def make_informer(): + return MagicMock(spec=Informer) + + def fail(): + raise QQError("fail") + + runner._run_pipeline([make_informer, fail, make_informer]) + + assert iterations == [2] + + +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) + + def fail(): + raise QQError("fail") + + runner._run_pipeline([fail, make_informer, fail]) + + assert len(runner.encountered_errors) == 2 + assert 1 in runner.encountered_errors + assert 3 in runner.encountered_errors + + +def test_command_runner_run_pipeline_reraises_unhandled_preparation_error(): + runner = CommandRunner(("111",), lambda _: None, MagicMock(), n_threads=1) + + def failing_target(): + raise RuntimeError("unexpected") + + with pytest.raises(RuntimeError, match="unexpected"): + runner._run_pipeline([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.object(runner, "_build_targets", return_value=[make_informer]), + patch.object(runner, "_run_pipeline"), + ): + try: + runner.run() + assert False, "Expected SystemExit" + except SystemExit as e: + assert e.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 + + 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 + + mock_logger.critical.assert_called_once() + + +def test_command_runnerrun_with_not_suitable_handler_single_job(): + callback = MagicMock(side_effect=QQNotSuitableError("not suitable")) + mock_logger = MagicMock() + + def make_informer(): + return MagicMock(spec=Informer) + + 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]), + ): + try: + runner.run() + assert False, "Expected SystemExit" + except SystemExit as e: + assert e.code == CFG.exit_codes.default + + +def test_command_runner_run_preserves_order_end_to_end(): + import time + + 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) + + def make_target(job_id, delay): + def target(): + time.sleep(delay) + informer = MagicMock(spec=Informer) + informer.job_id = job_id + return informer + + 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), + ], + ): + try: + runner.run() + assert False, "Expected SystemExit" + except SystemExit as e: + assert e.code == 0 + + assert execution_order == ["111", "222", "333"] diff --git a/tests/test_core_error_handlers.py b/tests/test_core_error_handlers.py index cbf9fa6..9b06109 100644 --- a/tests/test_core_error_handlers.py +++ b/tests/test_core_error_handlers.py @@ -6,6 +6,7 @@ import pytest +from qq_lib.core.command_runner import CommandRunner from qq_lib.core.error import QQNotSuitableError from qq_lib.core.error_handlers import ( CFG, @@ -13,12 +14,11 @@ handle_job_mismatch_error, handle_not_suitable_error, ) -from qq_lib.core.repeater import Repeater def test_not_suitable_single_item_logs_error_and_exits(): - metadata = Repeater.__new__(Repeater) - metadata.items = ["item1"] + metadata = CommandRunner.__new__(CommandRunner) + metadata.n_jobs = 1 metadata.encountered_errors = {} exc = QQNotSuitableError("not suitable") @@ -33,8 +33,8 @@ def test_not_suitable_single_item_logs_error_and_exits(): def test_not_suitable_multiple_items_logs_info(): - metadata = Repeater.__new__(Repeater) - metadata.items = ["item1", "item2"] + metadata = CommandRunner.__new__(CommandRunner) + metadata.n_jobs = 2 metadata.encountered_errors = {} exc = QQNotSuitableError("not suitable") @@ -49,8 +49,8 @@ def test_not_suitable_multiple_items_logs_info(): def test_not_suitable_multiple_items_multiple_errors_logs_and_exits(): - metadata = Repeater.__new__(Repeater) - metadata.items = ["item1", "item2"] + metadata = CommandRunner.__new__(CommandRunner) + metadata.n_jobs = 2 metadata.encountered_errors = { 0: QQNotSuitableError("not suitable"), 1: QQNotSuitableError("not suitable"), @@ -68,12 +68,10 @@ def test_not_suitable_multiple_items_multiple_errors_logs_and_exits(): mock_exit.assert_called_once_with(CFG.exit_codes.default) -@pytest.mark.parametrize( - "jobs", [["item1"], ["item1", "item2"], ["item1", "item2", "item3"]] -) -def test_job_mismatch_logs_and_exits(jobs): - metadata = Repeater.__new__(Repeater) - metadata.items = jobs +@pytest.mark.parametrize("n_jobs", [1, 2, 3]) +def test_job_mismatch_logs_and_exits(n_jobs): + metadata = CommandRunner.__new__(CommandRunner) + metadata.n_jobs = n_jobs metadata.encountered_errors = {} exc = RuntimeError("job mismatch") @@ -88,8 +86,8 @@ def test_job_mismatch_logs_and_exits(jobs): def test_job_general_qq_error_single_item_logs_and_exits(): - metadata = Repeater.__new__(Repeater) - metadata.items = ["item1"] + metadata = CommandRunner.__new__(CommandRunner) + metadata.n_jobs = 1 metadata.encountered_errors = {RuntimeError("general error")} exc = RuntimeError("general error") @@ -104,8 +102,8 @@ def test_job_general_qq_error_single_item_logs_and_exits(): def test_job_general_qq_error_multiple_items_logs(): - metadata = Repeater.__new__(Repeater) - metadata.items = ["item1", "item2"] + metadata = CommandRunner.__new__(CommandRunner) + metadata.n_jobs = 2 metadata.encountered_errors = {0: RuntimeError("general error")} exc = RuntimeError("general error") @@ -120,8 +118,8 @@ def test_job_general_qq_error_multiple_items_logs(): def test_job_general_qq_error_multiple_items_multiple_errors_logs_and_exits(): - metadata = Repeater.__new__(Repeater) - metadata.items = ["item1", "item2"] + metadata = CommandRunner.__new__(CommandRunner) + metadata.n_jobs = 2 metadata.encountered_errors = { 0: RuntimeError("general error"), 1: QQNotSuitableError("not suitable"), diff --git a/tests/test_go_cli.py b/tests/test_go_cli.py index a8030af..4eb0376 100644 --- a/tests/test_go_cli.py +++ b/tests/test_go_cli.py @@ -8,7 +8,11 @@ from qq_lib.core.config import CFG from qq_lib.core.error import QQError, QQNotSuitableError -from qq_lib.go.cli import _go_to_job, go +from qq_lib.core.error_handlers import ( + handle_general_qq_error, + handle_not_suitable_error, +) +from qq_lib.go.cli import _go_to_job, go, logger def test_go_to_job_calls_printinfo_ensure_suitable_and_go(): @@ -26,85 +30,48 @@ def test_go_to_job_calls_printinfo_ensure_suitable_and_go(): goer_mock.go.assert_called_once() -def test_go_invokes_repeater_and_exits_success(tmp_path): - dummy_file = tmp_path / "job.qqinfo" - dummy_file.write_text("dummy") - +def test_go_creates_command_runner_and_runs(): runner = CliRunner() - repeater_mock = MagicMock() - informer_mock = MagicMock() - - with ( - patch("qq_lib.go.cli.get_info_files", return_value=[dummy_file]), - patch("qq_lib.go.cli.Informer.from_file", return_value=informer_mock), - patch("qq_lib.go.cli.Repeater", return_value=repeater_mock), - patch("qq_lib.go.cli.logger"), - ): - result = runner.invoke(go, []) - assert result.exit_code == 0 - calls = [call_args[0][0] for call_args in repeater_mock.on_exception.call_args_list] - assert QQNotSuitableError in calls - assert QQError in calls - repeater_mock.run.assert_called_once() - - -def test_go_invokes_repeater_and_exits_success_with_job_id(): - runner = CliRunner() - repeater_mock = MagicMock() - informer_mock = MagicMock() + 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) - with ( - patch("qq_lib.go.cli.Informer.from_job_id", return_value=informer_mock), - patch("qq_lib.go.cli.Repeater", return_value=repeater_mock), - patch("qq_lib.go.cli.logger"), - ): - result = runner.invoke(go, ["12345"]) + result = runner.invoke(go, ["111", "222"]) assert result.exit_code == 0 - calls = [call_args[0][0] for call_args in repeater_mock.on_exception.call_args_list] - assert QQNotSuitableError in calls - assert QQError in calls - repeater_mock.run.assert_called_once() + mock_cls.assert_called_once_with( + ("111", "222"), + _go_to_job, + logger, + n_threads=CFG.parallelization_options.job_info_max_threads, + ) + mock_cls.return_value.run.assert_called_once() -def test_go_catches_qqerror_and_exits_91(tmp_path): - dummy_file = tmp_path / "job.qqinfo" - dummy_file.write_text("dummy") - +def test_go_registers_exception_handlers(): runner = CliRunner() - repeater_mock = MagicMock() - repeater_mock.run.side_effect = QQError("error occurred") - informer_mock = MagicMock() - with ( - patch("qq_lib.go.cli.get_info_files", return_value=[dummy_file]), - patch("qq_lib.go.cli.Informer.from_file", return_value=informer_mock), - patch("qq_lib.go.cli.Repeater", return_value=repeater_mock), - patch("qq_lib.go.cli.logger") as mock_logger, - ): - result = runner.invoke(go, []) + 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) - assert result.exit_code == CFG.exit_codes.default - mock_logger.error.assert_called_once_with(repeater_mock.run.side_effect) + runner.invoke(go, []) + handlers = { + c[0][0]: c[0][1] for c in mock_cls.return_value.on_exception.call_args_list + } + assert handlers[QQNotSuitableError] is handle_not_suitable_error + assert handlers[QQError] is handle_general_qq_error -def test_go_catches_generic_exception_and_exits_99(tmp_path): - dummy_file = tmp_path / "job.qqinfo" - dummy_file.write_text("dummy") +def test_go_without_args_passes_empty_tuple(): runner = CliRunner() - repeater_mock = MagicMock() - repeater_mock.run.side_effect = Exception("fatal error") - informer_mock = MagicMock() - with ( - patch("qq_lib.go.cli.get_info_files", return_value=[dummy_file]), - patch("qq_lib.go.cli.Informer.from_file", return_value=informer_mock), - patch("qq_lib.go.cli.Repeater", return_value=repeater_mock), - patch("qq_lib.go.cli.logger") as mock_logger, - ): - result = runner.invoke(go, []) + 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) + + runner.invoke(go, []) - assert result.exit_code == CFG.exit_codes.unexpected_error - mock_logger.critical.assert_called_once() + assert mock_cls.call_args[0][0] == () diff --git a/tests/test_info_cli.py b/tests/test_info_cli.py index ebfbed5..32d56f5 100644 --- a/tests/test_info_cli.py +++ b/tests/test_info_cli.py @@ -8,7 +8,8 @@ from qq_lib.core.config import CFG from qq_lib.core.error import QQError -from qq_lib.info.cli import _info_for_job, info +from qq_lib.core.error_handlers import handle_general_qq_error +from qq_lib.info.cli import _info_for_job, info, logger def test_info_for_job_short_prints_short_info(): @@ -53,83 +54,48 @@ def test_info_for_job_full_prints_full_info_panel(): console_instance.print.assert_called_once_with(panel_mock) -def test_info_invokes_repeater_and_exits_success(tmp_path): - dummy_file = tmp_path / "job.qqinfo" - dummy_file.write_text("dummy") - - repeater_mock = MagicMock() - informer_mock = MagicMock() - +def test_info_creates_command_runner_and_runs(): runner = CliRunner() - with ( - patch("qq_lib.info.cli.get_info_files", return_value=[dummy_file]), - patch("qq_lib.info.cli.Informer.from_file", return_value=informer_mock), - patch("qq_lib.info.cli.Repeater", return_value=repeater_mock), - patch("qq_lib.info.cli.logger"), - ): - result = runner.invoke(info, []) - - assert result.exit_code == 0 - repeater_mock.run.assert_called_once() + 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, ["111"]) -def test_info_catches_qqerror_and_exits_91(tmp_path): - dummy_file = tmp_path / "job.qqinfo" - dummy_file.write_text("dummy") + assert result.exit_code == 0 + mock_cls.assert_called_once_with( + ("111",), + _info_for_job, + logger, + False, + n_threads=CFG.parallelization_options.job_info_max_threads, + ) + mock_cls.return_value.run.assert_called_once() - repeater_mock = MagicMock() - informer_mock = MagicMock() - repeater_mock.run.side_effect = QQError("error occurred") +def test_info_passes_short_flag(): runner = CliRunner() - with ( - patch("qq_lib.info.cli.get_info_files", return_value=[dummy_file]), - patch("qq_lib.info.cli.Informer.from_file", return_value=informer_mock), - patch("qq_lib.info.cli.Repeater", return_value=repeater_mock), - patch("qq_lib.info.cli.logger") as mock_logger, - ): - result = runner.invoke(info, []) - - assert result.exit_code == CFG.exit_codes.default - mock_logger.error.assert_called_once_with(repeater_mock.run.side_effect) + 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) + runner.invoke(info, ["--short", "111"]) -def test_info_catches_generic_exception_and_exits_99(tmp_path): - dummy_file = tmp_path / "job.qqinfo" - dummy_file.write_text("dummy") + assert mock_cls.call_args[0][3] is True - repeater_mock = MagicMock() - informer_mock = MagicMock() - repeater_mock.run.side_effect = Exception("fatal error") +def test_info_registers_exception_handlers(): runner = CliRunner() - with ( - patch("qq_lib.info.cli.get_info_files", return_value=[dummy_file]), - patch("qq_lib.info.cli.Informer.from_file", return_value=informer_mock), - patch("qq_lib.info.cli.Repeater", return_value=repeater_mock), - patch("qq_lib.info.cli.logger") as mock_logger, - ): - result = runner.invoke(info, []) - - assert result.exit_code == CFG.exit_codes.unexpected_error - mock_logger.critical.assert_called_once() - - -def test_info_invokes_repeater_with_job_id_and_exits_success(): - repeater_mock = MagicMock() - informer_mock = MagicMock() - - 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) - with ( - patch("qq_lib.info.cli.Informer.from_job_id", return_value=informer_mock), - patch("qq_lib.info.cli.Repeater", return_value=repeater_mock), - patch("qq_lib.info.cli.logger"), - ): - result = runner.invoke(info, ["12345"]) + runner.invoke(info, []) - assert result.exit_code == 0 - repeater_mock.run.assert_called_once() + handlers = { + c[0][0]: c[0][1] for c in mock_cls.return_value.on_exception.call_args_list + } + assert handlers[QQError] is handle_general_qq_error diff --git a/tests/test_kill_cli.py b/tests/test_kill_cli.py index f127e7e..2e0e3e6 100644 --- a/tests/test_kill_cli.py +++ b/tests/test_kill_cli.py @@ -8,7 +8,11 @@ from qq_lib.core.config import CFG from qq_lib.core.error import QQError, QQNotSuitableError -from qq_lib.kill.cli import kill, kill_job +from qq_lib.core.error_handlers import ( + handle_general_qq_error, + handle_not_suitable_error, +) +from qq_lib.kill.cli import kill, kill_job, logger def test_kill_job_force_skips_suitability_and_logs_killed(): @@ -64,84 +68,52 @@ def test_kill_job_prompts_no_and_aborts(): mock_logger.assert_called_once_with("Operation aborted.") -def test_kill_invokes_repeater_and_exits_success(tmp_path): - dummy_file = tmp_path / "info.qq" - dummy_file.write_text("dummy") - +def test_kill_creates_command_runner_and_runs(): runner = CliRunner() - repeater_mock = MagicMock() - informer_mock = MagicMock() - - with ( - patch("qq_lib.kill.cli.get_info_files", return_value=[dummy_file]), - patch("qq_lib.kill.cli.Informer.from_file", return_value=informer_mock), - patch("qq_lib.kill.cli.Repeater", return_value=repeater_mock), - patch("qq_lib.kill.cli.logger"), - ): - result = runner.invoke(kill, []) - - assert result.exit_code == 0 - - calls = [c[0][0] for c in repeater_mock.on_exception.call_args_list] - assert QQNotSuitableError in calls - assert QQError in calls - - repeater_mock.run.assert_called_once() + 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) -def test_kill_catches_qqerror_and_exits_91(tmp_path): - dummy_file = tmp_path / "info.qq" - dummy_file.write_text("dummy") + result = runner.invoke(kill, ["111"]) + assert result.exit_code == 0 + mock_cls.assert_called_once_with( + ("111",), + 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_passes_force_and_yes_flags(): runner = CliRunner() - repeater_mock = MagicMock() - informer_mock = MagicMock() - repeater_mock.run.side_effect = QQError("error occurred") - with ( - patch("qq_lib.kill.cli.get_info_files", return_value=[dummy_file]), - patch("qq_lib.kill.cli.Informer.from_file", return_value=informer_mock), - patch("qq_lib.kill.cli.Repeater", return_value=repeater_mock), - patch("qq_lib.kill.cli.logger") as mock_logger, - ): - result = runner.invoke(kill, []) + 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) - assert result.exit_code == CFG.exit_codes.default - mock_logger.error.assert_called_once_with(repeater_mock.run.side_effect) + 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 -def test_kill_catches_generic_exception_and_exits_99(tmp_path): - dummy_file = tmp_path / "info.qq" - dummy_file.write_text("dummy") +def test_kill_registers_exception_handlers(): runner = CliRunner() - repeater_mock = MagicMock() - informer_mock = MagicMock() - repeater_mock.run.side_effect = Exception("critical error") - - with ( - patch("qq_lib.kill.cli.get_info_files", return_value=[dummy_file]), - patch("qq_lib.kill.cli.Informer.from_file", return_value=informer_mock), - patch("qq_lib.kill.cli.Repeater", return_value=repeater_mock), - patch("qq_lib.kill.cli.logger") as mock_logger, - ): - result = runner.invoke(kill, []) - - assert result.exit_code == CFG.exit_codes.unexpected_error - mock_logger.critical.assert_called_once() + 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) -def test_kill_with_job_id_invokes_repeater(): - runner = CliRunner() - repeater_mock = MagicMock() - informer_mock = MagicMock() + runner.invoke(kill, []) - with ( - patch("qq_lib.kill.cli.Informer.from_job_id", return_value=informer_mock), - patch("qq_lib.kill.cli.Repeater", return_value=repeater_mock), - patch("qq_lib.kill.cli.logger"), - ): - result = runner.invoke(kill, ["12345"]) - - assert result.exit_code == 0 - repeater_mock.run.assert_called_once() + handlers = { + c[0][0]: c[0][1] for c in mock_cls.return_value.on_exception.call_args_list + } + assert handlers[QQNotSuitableError] is handle_not_suitable_error + assert handlers[QQError] is handle_general_qq_error diff --git a/tests/test_respawn_cli.py b/tests/test_respawn_cli.py index 376b937..f9962c5 100644 --- a/tests/test_respawn_cli.py +++ b/tests/test_respawn_cli.py @@ -10,8 +10,12 @@ from qq_lib.core.config import CFG from qq_lib.core.error import QQError, QQNotSuitableError +from qq_lib.core.error_handlers import ( + handle_general_qq_error, + handle_not_suitable_error, +) from qq_lib.properties.states import RealState -from qq_lib.respawn.cli import respawn, respawn_job +from qq_lib.respawn.cli import _respawn_job, logger, respawn from qq_lib.respawn.respawner import Respawner @@ -32,7 +36,7 @@ def test_respawn_job_succeeds_when_failed(informer_for_respawn): patch.object(Respawner, "print_info"), patch("qq_lib.respawn.cli.logger") as mock_logger, ): - respawn_job(informer_for_respawn) + _respawn_job(informer_for_respawn) mock_logger.info.assert_any_call("Job '111' successfully respawned as '222'.") @@ -45,7 +49,7 @@ def test_respawn_job_succeeds_when_killed(informer_for_respawn): patch.object(Respawner, "print_info"), patch("qq_lib.respawn.cli.logger") as mock_logger, ): - respawn_job(informer_for_respawn) + _respawn_job(informer_for_respawn) mock_logger.info.assert_any_call("Job '111' successfully respawned as '222'.") @@ -61,7 +65,7 @@ def test_respawn_job_raises_when_not_suitable(informer_for_respawn, state): patch.object(Respawner, "print_info"), pytest.raises(QQNotSuitableError, match="cannot be respawned"), ): - respawn_job(informer_for_respawn) + _respawn_job(informer_for_respawn) @pytest.mark.parametrize( @@ -76,7 +80,7 @@ def test_respawn_job_does_not_respawn_when_not_suitable(informer_for_respawn, st patch.object(Respawner, "print_info"), pytest.raises(QQNotSuitableError), ): - respawn_job(informer_for_respawn) + _respawn_job(informer_for_respawn) mock_respawn.assert_not_called() @@ -95,7 +99,7 @@ def test_respawn_job_prints_info_before_respawning(informer_for_respawn): side_effect=lambda: (call_order.append("respawn"), "222")[1], ), ): - respawn_job(informer_for_respawn) + _respawn_job(informer_for_respawn) assert call_order == ["print", "respawn"] @@ -108,79 +112,39 @@ def test_respawn_job_propagates_respawn_error(informer_for_respawn): patch.object(Respawner, "respawn", side_effect=QQError("submission failed")), pytest.raises(QQError, match="submission failed"), ): - respawn_job(informer_for_respawn) - + _respawn_job(informer_for_respawn) -def test_respawn_invokes_repeater_and_exits_success(tmp_path): - dummy_file = tmp_path / "info.qq" - dummy_file.write_text("dummy") +def test_respawn_creates_command_runner_and_runs(): runner = CliRunner() - repeater_mock = MagicMock() - informer_mock = MagicMock() - - with ( - patch("qq_lib.respawn.cli.get_info_files", return_value=[dummy_file]), - patch("qq_lib.respawn.cli.Informer.from_file", return_value=informer_mock), - patch("qq_lib.respawn.cli.Repeater", return_value=repeater_mock), - patch("qq_lib.respawn.cli.logger"), - ): - result = runner.invoke(respawn, []) - - assert result.exit_code == 0 - - calls = [c[0][0] for c in repeater_mock.on_exception.call_args_list] - assert QQNotSuitableError in calls - assert QQError in calls - - repeater_mock.run.assert_called_once() + 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) -def test_respawn_with_job_id_invokes_repeater(): - runner = CliRunner() - repeater_mock = MagicMock() - informer_mock = MagicMock() - - with ( - patch("qq_lib.respawn.cli.Informer.from_job_id", return_value=informer_mock), - patch("qq_lib.respawn.cli.Repeater", return_value=repeater_mock), - patch("qq_lib.respawn.cli.logger"), - ): - result = runner.invoke(respawn, ["12345"]) + result = runner.invoke(respawn, ["111"]) assert result.exit_code == 0 - repeater_mock.run.assert_called_once() + mock_cls.assert_called_once_with( + ("111",), + _respawn_job, + logger, + n_threads=CFG.parallelization_options.job_info_max_threads, + ) + mock_cls.return_value.run.assert_called_once() -def test_respawn_catches_qqerror_and_exits_with_default_code(): +def test_respawn_registers_exception_handlers(): runner = CliRunner() - repeater_mock = MagicMock() - repeater_mock.run.side_effect = QQError("error occurred") - informer_mock = MagicMock() - - with ( - patch("qq_lib.respawn.cli.Informer.from_job_id", return_value=informer_mock), - patch("qq_lib.respawn.cli.Repeater", return_value=repeater_mock), - patch("qq_lib.respawn.cli.logger") as mock_logger, - ): - result = runner.invoke(respawn, ["12345"]) - - assert result.exit_code == CFG.exit_codes.default - mock_logger.error.assert_called_once_with(repeater_mock.run.side_effect) + 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) -def test_respawn_catches_generic_exception_and_exits_with_unexpected_error_code(): - runner = CliRunner() - repeater_mock = MagicMock() - repeater_mock.run.side_effect = Exception("critical error") - informer_mock = MagicMock() - - with ( - patch("qq_lib.respawn.cli.Informer.from_job_id", return_value=informer_mock), - patch("qq_lib.respawn.cli.Repeater", return_value=repeater_mock), - patch("qq_lib.respawn.cli.logger") as mock_logger, - ): - result = runner.invoke(respawn, ["12345"]) + runner.invoke(respawn, []) - assert result.exit_code == CFG.exit_codes.unexpected_error - mock_logger.critical.assert_called_once() + handlers = { + c[0][0]: c[0][1] for c in mock_cls.return_value.on_exception.call_args_list + } + assert handlers[QQNotSuitableError] is handle_not_suitable_error + assert handlers[QQError] is handle_general_qq_error diff --git a/tests/test_sync_cli.py b/tests/test_sync_cli.py index d699599..28e7a40 100644 --- a/tests/test_sync_cli.py +++ b/tests/test_sync_cli.py @@ -8,7 +8,11 @@ from qq_lib.core.config import CFG from qq_lib.core.error import QQError, QQNotSuitableError -from qq_lib.sync.cli import _split_files, _sync_job, sync +from qq_lib.core.error_handlers import ( + handle_general_qq_error, + handle_not_suitable_error, +) +from qq_lib.sync.cli import _split_files, _sync_job, logger, sync def test_sync_job_calls_sync(): @@ -59,85 +63,59 @@ def test_split_files_splits_correctly(input_str, expected): assert _split_files(input_str) == expected -def test_sync_invokes_repeater_and_exits_success(tmp_path): - dummy_file = tmp_path / "job.qqinfo" - dummy_file.write_text("dummy") - +def test_sync_creates_command_runner_and_runs(): runner = CliRunner() - repeater_mock = MagicMock() - informer_mock = MagicMock() with ( - patch("qq_lib.sync.cli.get_info_files", return_value=[dummy_file]), - patch("qq_lib.sync.cli.Informer.from_file", return_value=informer_mock), - patch("qq_lib.sync.cli.Repeater", return_value=repeater_mock), - patch("qq_lib.sync.cli.logger"), + patch("qq_lib.sync.cli.CommandRunner") as mock_cls, + patch("qq_lib.sync.cli._split_files", return_value=["a.txt", "b.txt"]), ): - result = runner.invoke(sync, []) - - assert result.exit_code == 0 - calls = [c[0][0] for c in repeater_mock.on_exception.call_args_list] - assert QQNotSuitableError in calls - assert QQError in calls - repeater_mock.run.assert_called_once() - + mock_cls.return_value.on_exception.return_value = mock_cls.return_value + mock_cls.return_value.run.side_effect = SystemExit(0) -def test_sync_invokes_repeater_with_job_id_and_exits_success(): - runner = CliRunner() - repeater_mock = MagicMock() - informer_mock = MagicMock() - - with ( - patch("qq_lib.sync.cli.Informer.from_job_id", return_value=informer_mock), - patch("qq_lib.sync.cli.Repeater", return_value=repeater_mock), - patch("qq_lib.sync.cli.logger"), - ): - result = runner.invoke(sync, ["123"]) + result = runner.invoke(sync, ["111", "--files", "a.txt:b.txt"]) assert result.exit_code == 0 - calls = [c[0][0] for c in repeater_mock.on_exception.call_args_list] - assert QQNotSuitableError in calls - assert QQError in calls - repeater_mock.run.assert_called_once() + mock_cls.assert_called_once_with( + ("111",), + _sync_job, + logger, + ["a.txt", "b.txt"], + n_threads=CFG.parallelization_options.job_info_max_threads, + ) + mock_cls.return_value.run.assert_called_once() -def test_sync_catches_qqerror_and_exits_91(tmp_path): - dummy_file = tmp_path / "job.qqinfo" - dummy_file.write_text("dummy") - +def test_sync_without_files_passes_none(): runner = CliRunner() - informer_mock = MagicMock() - repeater_mock = MagicMock() - repeater_mock.run.side_effect = QQError("error occurred") with ( - patch("qq_lib.sync.cli.get_info_files", return_value=[dummy_file]), - patch("qq_lib.sync.cli.Informer.from_file", return_value=informer_mock), - patch("qq_lib.sync.cli.Repeater", return_value=repeater_mock), - patch("qq_lib.sync.cli.logger") as mock_logger, + patch("qq_lib.sync.cli.CommandRunner") as mock_cls, + patch("qq_lib.sync.cli._split_files", return_value=None) as mock_split, ): - result = runner.invoke(sync, []) + mock_cls.return_value.on_exception.return_value = mock_cls.return_value + mock_cls.return_value.run.side_effect = SystemExit(0) - assert result.exit_code == CFG.exit_codes.default - mock_logger.error.assert_called_once_with(repeater_mock.run.side_effect) + runner.invoke(sync, ["111"]) + mock_split.assert_called_once_with(None) + assert mock_cls.call_args[0][3] is None -def test_sync_catches_generic_exception_and_exits_99(tmp_path): - dummy_file = tmp_path / "job.qqinfo" - dummy_file.write_text("dummy") +def test_sync_registers_exception_handlers(): runner = CliRunner() - informer_mock = MagicMock() - repeater_mock = MagicMock() - repeater_mock.run.side_effect = Exception("fatal error") with ( - patch("qq_lib.sync.cli.get_info_files", return_value=[dummy_file]), - patch("qq_lib.sync.cli.Informer.from_file", return_value=informer_mock), - patch("qq_lib.sync.cli.Repeater", return_value=repeater_mock), - patch("qq_lib.sync.cli.logger") as mock_logger, + patch("qq_lib.sync.cli.CommandRunner") as mock_cls, + patch("qq_lib.sync.cli._split_files", return_value=None), ): - result = runner.invoke(sync, []) + mock_cls.return_value.on_exception.return_value = mock_cls.return_value + mock_cls.return_value.run.side_effect = SystemExit(0) + + runner.invoke(sync, []) - assert result.exit_code == CFG.exit_codes.unexpected_error - mock_logger.critical.assert_called_once() + handlers = { + c[0][0]: c[0][1] for c in mock_cls.return_value.on_exception.call_args_list + } + assert handlers[QQNotSuitableError] is handle_not_suitable_error + assert handlers[QQError] is handle_general_qq_error diff --git a/tests/test_wipe_cli.py b/tests/test_wipe_cli.py index edd4cb7..3cf3484 100644 --- a/tests/test_wipe_cli.py +++ b/tests/test_wipe_cli.py @@ -9,7 +9,11 @@ from qq_lib.core.config import CFG from qq_lib.core.error import QQError, QQNotSuitableError -from qq_lib.wipe.cli import _wipe_work_dir, wipe +from qq_lib.core.error_handlers import ( + handle_general_qq_error, + handle_not_suitable_error, +) +from qq_lib.wipe.cli import _wipe_work_dir, logger, wipe @patch("qq_lib.wipe.cli.logger.info") @@ -90,85 +94,51 @@ def test_wipe_work_dir_raises_general_error(mock_wiper_from_informer): _wipe_work_dir(informer, force=True, yes=True) -def test_wipe_invokes_repeater_and_exits_success(tmp_path): - dummy_file = tmp_path / "info.qq" - dummy_file.write_text("dummy") - +def test_wipe_creates_command_runner_and_runs(): runner = CliRunner() - repeater_mock = MagicMock() - informer_mock = MagicMock() - with ( - patch("qq_lib.wipe.cli.get_info_files", return_value=[dummy_file]), - patch("qq_lib.wipe.cli.Informer.from_file", return_value=informer_mock), - patch("qq_lib.wipe.cli.Repeater", return_value=repeater_mock), - patch("qq_lib.wipe.cli.logger"), - ): - result = runner.invoke(wipe, []) + 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, ["111"]) assert result.exit_code == 0 - calls = [c[0][0] for c in repeater_mock.on_exception.call_args_list] - assert QQNotSuitableError in calls - assert QQError in calls - repeater_mock.run.assert_called_once() + mock_cls.assert_called_once_with( + ("111",), + _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_invokes_repeater_with_job_id_and_exits_success(): +def test_wipe_passes_force_and_yes_flags(): runner = CliRunner() - repeater_mock = MagicMock() - informer_mock = MagicMock() - with ( - patch("qq_lib.wipe.cli.Informer.from_job_id", return_value=informer_mock), - patch("qq_lib.wipe.cli.Repeater", return_value=repeater_mock), - patch("qq_lib.wipe.cli.logger"), - ): - result = runner.invoke(wipe, ["123"]) + 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) - assert result.exit_code == 0 - calls = [c[0][0] for c in repeater_mock.on_exception.call_args_list] - assert QQNotSuitableError in calls - assert QQError in calls - repeater_mock.run.assert_called_once() + runner.invoke(wipe, ["--force", "--yes", "111"]) + assert mock_cls.call_args[0][3] is True + assert mock_cls.call_args[0][4] is True -def test_wipe_catches_qqerror_and_exits_91(tmp_path): - dummy_file = tmp_path / "info.qq" - dummy_file.write_text("dummy") +def test_wipe_registers_exception_handlers(): runner = CliRunner() - informer_mock = MagicMock() - repeater_mock = MagicMock() - repeater_mock.run.side_effect = QQError("wipe failure") - with ( - patch("qq_lib.wipe.cli.get_info_files", return_value=[dummy_file]), - patch("qq_lib.wipe.cli.Informer.from_file", return_value=informer_mock), - patch("qq_lib.wipe.cli.Repeater", return_value=repeater_mock), - patch("qq_lib.wipe.cli.logger") as mock_logger, - ): - result = runner.invoke(wipe, []) + 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) - assert result.exit_code == CFG.exit_codes.default - mock_logger.error.assert_called_once_with(repeater_mock.run.side_effect) + runner.invoke(wipe, []) - -def test_wipe_catches_generic_exception_and_exits_99(tmp_path): - dummy_file = tmp_path / "info.qq" - dummy_file.write_text("dummy") - - runner = CliRunner() - informer_mock = MagicMock() - repeater_mock = MagicMock() - repeater_mock.run.side_effect = Exception("unexpected wipe crash") - - with ( - patch("qq_lib.wipe.cli.get_info_files", return_value=[dummy_file]), - patch("qq_lib.wipe.cli.Informer.from_file", return_value=informer_mock), - patch("qq_lib.wipe.cli.Repeater", return_value=repeater_mock), - patch("qq_lib.wipe.cli.logger") as mock_logger, - ): - result = runner.invoke(wipe, []) - - assert result.exit_code == CFG.exit_codes.unexpected_error - mock_logger.critical.assert_called_once() + handlers = { + c[0][0]: c[0][1] for c in mock_cls.return_value.on_exception.call_args_list + } + assert handlers[QQNotSuitableError] is handle_not_suitable_error + assert handlers[QQError] is handle_general_qq_error From d9a9e0fbf5795944c59b0d326b2e0da741a788df Mon Sep 17 00:00:00 2001 From: Ladme Date: Sat, 9 May 2026 13:19:16 +0200 Subject: [PATCH 33/43] Updated changelog --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98b7867..cdbd050 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ ## Version 0.11 - Updated the installation scripts to more clearly report issues that occured during the install. +### qq respawn +- Failed or killed jobs can be now 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. + +### Specifying multiple job IDs +- `qq info`, `qq kill`, `qq sync`, `qq respawn`, `qq wipe`, and `qq go` now accept multiple job IDs as arguments. +- All of these commands now also internally resolve the jobs in parallel making them much faster when dealing with a large number of jobs. + ### Better support for non-bash interpreters - Interpreters now support additional command-line arguments. From be8c5b7c4b4471f6af9a1bcb8b352215a4fe6282 Mon Sep 17 00:00:00 2001 From: Ladme Date: Sat, 9 May 2026 15:08:04 +0200 Subject: [PATCH 34/43] Removed unused method --- src/qq_lib/properties/loop.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/src/qq_lib/properties/loop.py b/src/qq_lib/properties/loop.py index 5485843..925542c 100644 --- a/src/qq_lib/properties/loop.py +++ b/src/qq_lib/properties/loop.py @@ -156,26 +156,6 @@ def to_dict(self) -> dict[str, object]: "archive_mode": [mode.to_str() for mode in self.archive_mode], } - def to_command_line(self) -> list[str]: - """ - Convert loop job settings into a command-line argument list for `qq submit`. - - Returns: - list[str]: A list of command-line arguments ready to pass to ``qq submit``. - """ - return [ - "--loop-start", - str(self.start), - "--loop-end", - str(self.end), - "--archive", - self.archive.name, - "--archive-format", - self.archive_format, - "--archive-mode", - ":".join(mode.to_str() for mode in self.archive_mode), - ] - def determine_cycle_from_archive(self) -> int: """ Determine the current cycle number based on files in the archive directory. From 5d58bdab9716dd6e681e7b696a3a56fe410ed6d2 Mon Sep 17 00:00:00 2001 From: Ladme Date: Sat, 9 May 2026 15:10:07 +0200 Subject: [PATCH 35/43] Wording --- src/qq_lib/respawn/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qq_lib/respawn/__init__.py b/src/qq_lib/respawn/__init__.py index db648c8..42504c6 100644 --- a/src/qq_lib/respawn/__init__.py +++ b/src/qq_lib/respawn/__init__.py @@ -8,7 +8,7 @@ whether a failed or killed job can be respawned and to resubmit it with its original parameters. -Respawning involves cleaning up the working directory, clearing generated files +Respawning involves cleaning up the working directory, clearing runtime files from the input directory, and submitting a fresh copy of the job. For loop jobs, the archive directory is checked for consistency before resubmission. """ From ed4db98a7015347fae6104ed1b0577e5faeeb79f Mon Sep 17 00:00:00 2001 From: Ladme Date: Sun, 10 May 2026 09:08:58 +0200 Subject: [PATCH 36/43] Changed the way resubmission hosts are propagated --- src/qq_lib/respawn/respawner.py | 1 + src/qq_lib/resubmit/__init__.py | 16 ++ src/qq_lib/{run => resubmit}/resubmitter.py | 43 ++-- src/qq_lib/run/__init__.py | 5 - src/qq_lib/run/runner.py | 2 +- src/qq_lib/submit/factory.py | 44 ++-- src/qq_lib/submit/submitter.py | 4 +- tests/test_respawn_respawner.py | 1 + ...mitter.py => test_resubmit_resubmitter.py} | 75 +++---- tests/test_submit_factory.py | 188 +++++++++++++----- tests/test_submit_submitter.py | 10 +- 11 files changed, 227 insertions(+), 162 deletions(-) create mode 100644 src/qq_lib/resubmit/__init__.py rename src/qq_lib/{run => resubmit}/resubmitter.py (83%) rename tests/{test_run_resubmitter.py => test_resubmit_resubmitter.py} (75%) diff --git a/src/qq_lib/respawn/respawner.py b/src/qq_lib/respawn/respawner.py index debc82d..5ce9cae 100644 --- a/src/qq_lib/respawn/respawner.py +++ b/src/qq_lib/respawn/respawner.py @@ -101,6 +101,7 @@ def _build_submitter(self, informer: Informer) -> Submitter: transfer_mode=informer.info.transfer_mode, server=informer.info.server, interpreter=informer.info.interpreter, + resubmit_from=informer.info.resubmit_from, ) @staticmethod diff --git a/src/qq_lib/resubmit/__init__.py b/src/qq_lib/resubmit/__init__.py new file mode 100644 index 0000000..22cf3f2 --- /dev/null +++ b/src/qq_lib/resubmit/__init__.py @@ -0,0 +1,16 @@ +# Released under MIT License. +# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab + +""" +Execution utilities for resubmitting qq continuous and loop jobs inside the batch environment. + +This module defines the `Resubmitter` class, which handles resubmission of loop +and continuous jobs by resolving candidate hosts and attempting submission +on each in order until one succeeds. +""" + +from .resubmitter import Resubmitter + +__all__ = [ + "Resubmitter", +] diff --git a/src/qq_lib/run/resubmitter.py b/src/qq_lib/resubmit/resubmitter.py similarity index 83% rename from src/qq_lib/run/resubmitter.py rename to src/qq_lib/resubmit/resubmitter.py index b66c766..2f5033c 100644 --- a/src/qq_lib/run/resubmitter.py +++ b/src/qq_lib/resubmit/resubmitter.py @@ -41,7 +41,12 @@ def resubmit(self) -> str: self._advance_loop_cycle(informer) submitter = self._build_submitter(informer, input_dir) - hosts = self._resolve_resubmit_hosts(informer) + hosts = ( + informer.info.resubmit_from + # fall back to batch system default + # this is only needed to accomodate transition from loop jobs submitted using previous versions of qq + or informer.batch_system.get_default_resubmit_hosts() + ) return self._try_resubmit(submitter, informer, hosts) @@ -86,34 +91,12 @@ def _build_submitter(informer: Informer, input_dir: Path) -> Submitter: transfer_mode=informer.info.transfer_mode, server=informer.info.server, interpreter=informer.info.interpreter, - resubmit_from=informer.info.resubmit_from, + resubmit_from=informer.info.resubmit_from + # fall back to batch system default + # this is only needed to accommodate transition from loop jobs submitted using previous versions of qq + or informer.batch_system.get_default_resubmit_hosts(), ) - @staticmethod - def _resolve_resubmit_hosts(informer: Informer) -> list[ResubmitHost]: - """ - Determine the list of hosts to attempt resubmission from. - - Hosts are resolved using a three-tier fallback: - 1. Hosts explicitly specified in the job metadata. - 2. Default hosts from the configuration file. - 3. Default hosts for the batch system. - - Args: - informer (Informer): The informer instance holding job metadata. - - Returns: - list[ResubmitHost]: Ordered list of candidate resubmission hosts. - """ - hosts = informer.info.resubmit_from - if not hosts: - if raw_hosts := CFG.resubmitter.default_resubmit_hosts: - hosts = ResubmitHost.multi_from_str(raw_hosts) - else: - hosts = informer.batch_system.get_default_resubmit_hosts() - - return hosts - @staticmethod def _try_resubmit( submitter: Submitter, @@ -133,6 +116,7 @@ def _try_resubmit( Raises: QQError: If the main node is not defined in the job metadata. + QQError: If the list of resubmission hosts is empty. QQError: If resubmission fails on all candidate hosts. """ # get the main node for host resolution @@ -143,6 +127,11 @@ def _try_resubmit( "Job cannot be resubmitted. The 'main_node' of the job is not defined." ) + if not hosts: + raise QQError( + "Job cannot be resubmitted. No resubmission hosts defined. This is a bug." + ) + for host in hosts: hostname = host.resolve(informer.info.input_machine, main_node) logger.info(f"Resubmitting from host '{hostname}'.") diff --git a/src/qq_lib/run/__init__.py b/src/qq_lib/run/__init__.py index f0e7d10..5e8dba0 100644 --- a/src/qq_lib/run/__init__.py +++ b/src/qq_lib/run/__init__.py @@ -9,15 +9,10 @@ and performs cleanup on success, failure, or interruption. It handles both shared and scratch working directories, loop-job archiving, resubmission, communication with the batch system, and SIGTERM-safe shutdown. - -It also defines the `Resubmitter` class, which handles resubmission of loop -and continuous jobs by resolving candidate hosts and attempting submission -on each in order until one succeeds. """ from .runner import Runner __all__ = [ "Runner", - "Resubmitter", ] diff --git a/src/qq_lib/run/runner.py b/src/qq_lib/run/runner.py index d1596e6..1b59f3d 100644 --- a/src/qq_lib/run/runner.py +++ b/src/qq_lib/run/runner.py @@ -31,7 +31,7 @@ from qq_lib.info.informer import Informer from qq_lib.properties.job_type import JobType from qq_lib.properties.states import NaiveState -from qq_lib.run.resubmitter import Resubmitter +from qq_lib.resubmit.resubmitter import Resubmitter logger = get_logger(__name__, show_time=True) diff --git a/src/qq_lib/submit/factory.py b/src/qq_lib/submit/factory.py index da112b1..be66298 100644 --- a/src/qq_lib/submit/factory.py +++ b/src/qq_lib/submit/factory.py @@ -4,7 +4,7 @@ from dataclasses import fields from pathlib import Path -from qq_lib.batch.interface import BatchInterface +from qq_lib.batch.interface import AnyBatchClass, BatchInterface 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 @@ -64,20 +64,22 @@ def make_submitter(self) -> Submitter: server = self._get_server() return Submitter( - BatchSystem, - queue, - self._get_account(), - self._script, - job_type, - self._get_resources(BatchSystem, queue, server), - loop_info, - self._get_exclude(), - self._get_include(), - self._get_depend(), - self._get_transfer_mode(), - server, - self._get_interpreter(), - self._get_resubmit_from(), + batch_system=BatchSystem, + queue=queue, + account=self._get_account(), + script=self._script, + job_type=job_type, + resources=self._get_resources(BatchSystem, queue, server), + loop_info=loop_info, + exclude=self._get_exclude(), + include=self._get_include(), + depend=self._get_depend(), + transfer_mode=self._get_transfer_mode(), + server=server, + interpreter=self._get_interpreter(), + resubmit_from=self._get_resubmit_from(BatchSystem) + if job_type in {JobType.LOOP, JobType.CONTINUOUS} + else None, ) def _get_batch_system(self) -> type[BatchInterface]: @@ -306,21 +308,27 @@ def _get_interpreter(self) -> Interpreter | None: return self._parser.get_interpreter() - def _get_resubmit_from(self) -> list[ResubmitHost]: + def _get_resubmit_from(self, BatchSystem: AnyBatchClass) -> list[ResubmitHost]: """ - Determine the list of resubmission hosts to be used - to resubmit loop/continuous job. + Determine the list of resubmission hosts to be used to resubmit loop/continuous job. Priority: 1. Resubmission hosts specified on the command line. 2. Resubmission hosts specified inside the submitted script. + 3. Resubmission hosts specified in the configuration file. + 4. Default resubmission hosts provided by the batch system. The lists are NOT merged. + Args: + BatchSystem (AnyBatchClass): The batch system used for job submission. + Returns: list[ResubmitHost]: List of resubmission hosts. """ return ( ResubmitHost.multi_from_str(self._kwargs.get("resubmit_from") or "") or self._parser.get_resubmit_from() + or ResubmitHost.multi_from_str(CFG.resubmitter.default_resubmit_hosts or "") + or BatchSystem.get_default_resubmit_hosts() ) diff --git a/src/qq_lib/submit/submitter.py b/src/qq_lib/submit/submitter.py index 134901c..3c52d15 100644 --- a/src/qq_lib/submit/submitter.py +++ b/src/qq_lib/submit/submitter.py @@ -89,7 +89,7 @@ def __init__( intepreter (Interpreter | None): Optional interpreter specification to use to execute the script. If not specified, the config default is used. resubmit_from (list[ResubmitHost] | None): List of hosts from which a loop/continuous job should be resubmitted. - If not specified, the config or batch system default is used. + Must only be specified for loop/continuous jobs! Raises: QQError: If the script does not exist or has an invalid shebang line. @@ -171,7 +171,7 @@ def submit(self, remote: str | None = None) -> str: script_name=self._script_name, queue=self._queue, job_type=self._job_type, - input_machine=remote or socket.getfqdn(), + input_machine=socket.getfqdn(remote or ""), input_dir=self._input_dir, job_state=NaiveState.QUEUED, submission_time=datetime.now(), diff --git a/tests/test_respawn_respawner.py b/tests/test_respawn_respawner.py index 0eeae4d..5d8e63a 100644 --- a/tests/test_respawn_respawner.py +++ b/tests/test_respawn_respawner.py @@ -137,6 +137,7 @@ def test_respawner_build_submitter_creates_submitter_with_correct_params( transfer_mode=informer.info.transfer_mode, server=informer.info.server, interpreter=informer.info.interpreter, + resubmit_from=informer.info.resubmit_from, ) diff --git a/tests/test_run_resubmitter.py b/tests/test_resubmit_resubmitter.py similarity index 75% rename from tests/test_run_resubmitter.py rename to tests/test_resubmit_resubmitter.py index 57f711a..27d83a8 100644 --- a/tests/test_run_resubmitter.py +++ b/tests/test_resubmit_resubmitter.py @@ -15,7 +15,7 @@ ResubmitHost, WorkHost, ) -from qq_lib.run.resubmitter import Resubmitter +from qq_lib.resubmit.resubmitter import Resubmitter def test_resubmitter_advance_loop_cycle_increments_when_loop_info_exists(): @@ -41,7 +41,7 @@ def test_resubmitter_build_submitter_creates_submitter_with_correct_params(): informer.info.job_id = "12345" input_dir = Path("/tmp/input") - with patch("qq_lib.run.resubmitter.Submitter") as mock_submitter_cls: + with patch("qq_lib.resubmit.resubmitter.Submitter") as mock_submitter_cls: Resubmitter._build_submitter(informer, input_dir) mock_submitter_cls.assert_called_once_with( @@ -67,7 +67,7 @@ def test_resubmitter_build_submitter_depends_on_current_job(): informer.info.job_id = "99999" input_dir = Path("/tmp/input") - with patch("qq_lib.run.resubmitter.Submitter") as mock_submitter_cls: + with patch("qq_lib.resubmit.resubmitter.Submitter") as mock_submitter_cls: Resubmitter._build_submitter(informer, input_dir) call_kwargs = mock_submitter_cls.call_args.kwargs @@ -81,47 +81,13 @@ def test_resubmitter_build_submitter_constructs_script_path(): informer.info.script_name = "run.sh" input_dir = Path("/home/user/jobs") - with patch("qq_lib.run.resubmitter.Submitter") as mock_submitter_cls: + with patch("qq_lib.resubmit.resubmitter.Submitter") as mock_submitter_cls: Resubmitter._build_submitter(informer, input_dir) call_kwargs = mock_submitter_cls.call_args.kwargs assert call_kwargs["script"] == Path("/home/user/jobs/run.sh") -def test_resubmitter_resolve_resubmit_hosts_uses_job_metadata_when_available(): - informer = MagicMock() - informer.info.resubmit_from = [InputHost(), WorkHost()] - - result = Resubmitter._resolve_resubmit_hosts(informer) - - assert result == [InputHost(), WorkHost()] - - -def test_resubmitter_resolve_resubmit_hosts_falls_back_to_config(): - informer = MagicMock() - informer.info.resubmit_from = [] - - with patch("qq_lib.run.resubmitter.CFG") as mock_cfg: - mock_cfg.resubmitter.default_resubmit_hosts = "input:working" - result = Resubmitter._resolve_resubmit_hosts(informer) - - assert result == [InputHost(), WorkHost()] - - -def test_resubmitter_resolve_resubmit_hosts_falls_back_to_batch_system(): - informer = MagicMock() - informer.info.resubmit_from = [] - default_hosts = [InputHost(), WorkHost()] - informer.batch_system.get_default_resubmit_hosts.return_value = default_hosts - - with patch("qq_lib.run.resubmitter.CFG") as mock_cfg: - mock_cfg.resubmitter.default_resubmit_hosts = "" - result = Resubmitter._resolve_resubmit_hosts(informer) - - assert result is default_hosts - informer.batch_system.get_default_resubmit_hosts.assert_called_once() - - def test_resubmitter_try_resubmit_returns_job_id_on_success(): submitter = MagicMock() informer = MagicMock() @@ -129,7 +95,7 @@ def test_resubmitter_try_resubmit_returns_job_id_on_success(): informer.info.input_machine = "submit-node" hosts: list[ResubmitHost] = [InputHost()] - with patch("qq_lib.run.resubmitter.Retryer") as mock_retryer_cls: + with patch("qq_lib.resubmit.resubmitter.Retryer") as mock_retryer_cls: mock_retryer_cls.return_value.run.return_value = "67890" result = Resubmitter._try_resubmit(submitter, informer, hosts) @@ -142,7 +108,17 @@ def test_resubmitter_try_resubmit_raises_when_main_node_not_defined(): informer.info.main_node = "" hosts: list[ResubmitHost] = [InputHost()] - with pytest.raises(QQError, match="main_node"): + with pytest.raises(QQError, match="The 'main_node' of the job is not defined"): + Resubmitter._try_resubmit(submitter, informer, hosts) + + +def test_resubmitter_try_resubmit_raises_when_hosts_empty(): + submitter = MagicMock() + informer = MagicMock() + informer.info.main_node = "node01" + hosts: list[ResubmitHost] = [] + + with pytest.raises(QQError, match="No resubmission hosts defined"): Resubmitter._try_resubmit(submitter, informer, hosts) @@ -153,7 +129,7 @@ def test_resubmitter_try_resubmit_tries_next_host_on_failure(): informer.info.input_machine = "submit-node" hosts: list[ResubmitHost] = [InputHost(), ExplicitHost("fallback-node")] - with patch("qq_lib.run.resubmitter.Retryer") as mock_retryer_cls: + with patch("qq_lib.resubmit.resubmitter.Retryer") as mock_retryer_cls: mock_retryer_cls.return_value.run.side_effect = [ RuntimeError("connection refused"), "99999", @@ -172,7 +148,7 @@ def test_resubmitter_try_resubmit_raises_when_all_hosts_fail(): hosts: list[ResubmitHost] = [InputHost(), WorkHost()] with ( - patch("qq_lib.run.resubmitter.Retryer") as mock_retryer_cls, + patch("qq_lib.resubmit.resubmitter.Retryer") as mock_retryer_cls, pytest.raises(QQError, match="Could not resubmit the job"), ): mock_retryer_cls.return_value.run.side_effect = RuntimeError("failed") @@ -186,7 +162,7 @@ def test_resubmitter_try_resubmit_resolves_hostnames_correctly(): informer.info.input_machine = "login-01" hosts: list[ResubmitHost] = [InputHost(), WorkHost(), ExplicitHost("explicit-01")] - with patch("qq_lib.run.resubmitter.Retryer") as mock_retryer_cls: + with patch("qq_lib.resubmit.resubmitter.Retryer") as mock_retryer_cls: mock_retryer_cls.return_value.run.side_effect = [ RuntimeError("fail"), RuntimeError("fail"), @@ -208,8 +184,8 @@ def test_resubmitter_try_resubmit_uses_configured_retry_params(): hosts: list[ResubmitHost] = [InputHost()] with ( - patch("qq_lib.run.resubmitter.Retryer") as mock_retryer_cls, - patch("qq_lib.run.resubmitter.CFG") as mock_cfg, + patch("qq_lib.resubmit.resubmitter.Retryer") as mock_retryer_cls, + patch("qq_lib.resubmit.resubmitter.CFG") as mock_cfg, ): mock_cfg.resubmitter.retry_tries = 5 mock_cfg.resubmitter.retry_wait = 30 @@ -232,7 +208,7 @@ def test_try_resubmit_returns_on_first_success(): informer.info.input_machine = "submit-node" hosts: list[ResubmitHost] = [InputHost(), WorkHost(), ExplicitHost("node-03")] - with patch("qq_lib.run.resubmitter.Retryer") as mock_retryer_cls: + with patch("qq_lib.resubmit.resubmitter.Retryer") as mock_retryer_cls: mock_retryer_cls.return_value.run.return_value = "11111" result = Resubmitter._try_resubmit(submitter, informer, hosts) @@ -245,12 +221,12 @@ def test_resubmit_calls_methods_in_order(): resubmitter._info_file = Path("/tmp/input/job.info") informer = MagicMock() + informer.info.resubmit_from = [InputHost()] resubmitter.get_informer = MagicMock(return_value=informer) with ( patch.object(Resubmitter, "_advance_loop_cycle") as mock_advance, patch.object(Resubmitter, "_build_submitter") as mock_build, - patch.object(Resubmitter, "_resolve_resubmit_hosts") as mock_resolve, patch.object(Resubmitter, "_try_resubmit", return_value="12345") as mock_try, ): result = resubmitter.resubmit() @@ -258,7 +234,4 @@ def test_resubmit_calls_methods_in_order(): assert result == "12345" mock_advance.assert_called_once_with(informer) mock_build.assert_called_once_with(informer, Path("/tmp/input")) - mock_resolve.assert_called_once_with(informer) - mock_try.assert_called_once_with( - mock_build.return_value, informer, mock_resolve.return_value - ) + mock_try.assert_called_once_with(mock_build.return_value, informer, [InputHost()]) diff --git a/tests/test_submit_factory.py b/tests/test_submit_factory.py index 4ecc3b2..09f185a 100644 --- a/tests/test_submit_factory.py +++ b/tests/test_submit_factory.py @@ -14,7 +14,11 @@ from qq_lib.properties.job_type import JobType from qq_lib.properties.loop import LoopInfo from qq_lib.properties.resources import Resources -from qq_lib.properties.resubmit_host import InputHost, ResubmitHost, WorkHost +from qq_lib.properties.resubmit_host import ( + ExplicitHost, + InputHost, + WorkHost, +) from qq_lib.properties.size import Size from qq_lib.properties.transfer_mode import ( Always, @@ -601,37 +605,111 @@ def test_submitter_factory_get_interpreter_returns_none_if_no_cli_no_parser(): assert result is None -def test_submitter_factory_get_resubmit_from_from_command_line(): +@pytest.mark.parametrize( + "kwargs, parser_hosts, config_hosts, batch_hosts, expected", + [ + # command line overrides all + ( + {"resubmit_from": "node99"}, + [WorkHost()], + "input", + [InputHost()], + [ExplicitHost("node99")], + ), + # parser overrides config and batch + ( + {}, + [WorkHost(), ExplicitHost("node02")], + "input", + [InputHost()], + [WorkHost(), ExplicitHost("node02")], + ), + # config overrides batch + ( + {}, + [], + "working:node03", + [InputHost()], + [WorkHost(), ExplicitHost("node03")], + ), + # falls back to batch system + ( + {}, + [], + "", + [InputHost()], + [InputHost()], + ), + # empty command line falls through to parser + ( + {"resubmit_from": ""}, + [InputHost()], + "working", + [WorkHost()], + [InputHost()], + ), + # None config falls through to batch system + ( + {}, + [], + None, + [WorkHost()], + [WorkHost()], + ), + # command line, no parser, empty config + ( + {"resubmit_from": "node99:input"}, + [], + "", + [InputHost()], + [ExplicitHost("node99"), InputHost()], + ), + # command line, parser, empty config + ( + {"resubmit_from": "node99:input"}, + [WorkHost()], + "", + [InputHost()], + [ExplicitHost("node99"), InputHost()], + ), + # parser, empty config + ( + {}, + [InputHost()], + "", + [WorkHost()], + [InputHost()], + ), + ], + ids=[ + "command_line_overrides_all", + "parser_overrides_config_and_batch", + "config_overrides_batch", + "falls_back_to_batch_system", + "empty_command_line_falls_through", + "none_config_falls_through", + "command_line_no_parser_empty_config", + "command_line_parser_empty_config", + "parser_empty_config", + ], +) +def test_submitter_factory_get_resubmit_from_priority( + kwargs, parser_hosts, config_hosts, batch_hosts, expected +): mock_parser = MagicMock() - parser_resubmit_from = [MagicMock(), MagicMock()] - mock_parser.get_resubmit_from.return_value = parser_resubmit_from + mock_parser.get_resubmit_from.return_value = parser_hosts + mock_batch_system = MagicMock() + mock_batch_system.get_default_resubmit_hosts.return_value = batch_hosts factory = SubmitterFactory.__new__(SubmitterFactory) factory._parser = mock_parser - factory._kwargs = {"resubmit_from": "input,node01"} - - cli_resubmit_from = [MagicMock(), MagicMock()] + factory._kwargs = kwargs - with patch.object( - ResubmitHost, "multi_from_str", return_value=cli_resubmit_from - ) as mock_multi: - result = factory._get_resubmit_from() + with patch("qq_lib.submit.factory.CFG") as mock_cfg: + mock_cfg.resubmitter.default_resubmit_hosts = config_hosts + result = factory._get_resubmit_from(mock_batch_system) - mock_multi.assert_called_once_with("input,node01") - assert result == cli_resubmit_from - - -def test_submitter_factory_get_resubmit_from_from_parser(): - mock_parser = MagicMock() - parser_resubmit_from = [MagicMock(), MagicMock()] - mock_parser.get_resubmit_from.return_value = parser_resubmit_from - - factory = SubmitterFactory.__new__(SubmitterFactory) - factory._parser = mock_parser - factory._kwargs = {} - - result = factory._get_resubmit_from() - assert result == parser_resubmit_from + assert result == expected @pytest.mark.parametrize("server", [None, "fake.server.org"]) @@ -696,23 +774,23 @@ def test_submitter_factory_make_submitter_standard_job(server): 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_resubmit_from.assert_not_called() mock_submitter_class.assert_called_once_with( - BatchSystem, - queue, - account, - factory._script, - JobType.STANDARD, - resources, - None, # loop_info is None for STANDARD job - excludes, - includes, - depends, - transfer, - server, - interpreter, - resubmit_from, + batch_system=BatchSystem, + queue=queue, + account=account, + script=factory._script, + job_type=JobType.STANDARD, + resources=resources, + loop_info=None, # loop_info is None for STANDARD job + exclude=excludes, + include=includes, + depend=depends, + transfer_mode=transfer, + server=server, + interpreter=interpreter, + resubmit_from=None, ) assert result == mock_submit_instance @@ -785,19 +863,19 @@ def test_submitter_factory_make_submitter_loop_job(server): mock_resubmit_from.assert_called_once() mock_submitter_class.assert_called_once_with( - BatchSystem, - queue, - account, - factory._script, - JobType.LOOP, - resources, - loop_info, - excludes, - includes, - depends, - transfer, - server, - interpreter, - resubmit_from, + batch_system=BatchSystem, + queue=queue, + account=account, + script=factory._script, + job_type=JobType.LOOP, + resources=resources, + loop_info=loop_info, + exclude=excludes, + include=includes, + depend=depends, + transfer_mode=transfer, + server=server, + interpreter=interpreter, + resubmit_from=resubmit_from, ) assert result == mock_submit_instance diff --git a/tests/test_submit_submitter.py b/tests/test_submit_submitter.py index 8469ce3..b23e373 100644 --- a/tests/test_submit_submitter.py +++ b/tests/test_submit_submitter.py @@ -18,7 +18,7 @@ from qq_lib.properties.job_type import JobType from qq_lib.properties.loop import LoopInfo from qq_lib.properties.resources import Resources -from qq_lib.properties.resubmit_host import ExplicitHost, WorkHost +from qq_lib.properties.resubmit_host import ExplicitHost, InputHost, WorkHost from qq_lib.properties.states import NaiveState from qq_lib.properties.transfer_mode import Always, Success from qq_lib.submit.submitter import CFG, Submitter @@ -42,6 +42,9 @@ def test_submitter_init_sets_all_attributes_correctly(tmp_path): exclude=[Path("exclude")], include=[Path("include"), Path("/tmp/include")], transfer_mode=[Always()], + server="pbs-m1.metacentrum.cz", + interpreter=Interpreter(executable="bash"), + resubmit_from=[InputHost(), WorkHost()], ) assert submitter._batch_system == PBS @@ -58,8 +61,9 @@ def test_submitter_init_sets_all_attributes_correctly(tmp_path): assert submitter._include == [tmp_path / "include", Path("/tmp/include")] assert submitter._depend == [] assert isinstance(submitter._transfer_mode[0], Always) - assert submitter._server is None - assert submitter._resubmit_from == [] + assert submitter._server == "pbs-m1.metacentrum.cz" + assert submitter._interpreter == Interpreter(executable="bash") + assert submitter._resubmit_from == [InputHost(), WorkHost()] def test_submitter_init_raises_error_if_script_does_not_exist(tmp_path): From 4da2547908772a7f4d1950796bee671fd29bcee9 Mon Sep 17 00:00:00 2001 From: Ladme Date: Sun, 10 May 2026 09:14:32 +0200 Subject: [PATCH 37/43] Restructured tests --- tests/{ => archive}/test_archive.py | 0 tests/{ => batch/interface}/test_batch_interface.py | 0 tests/{ => batch/interface}/test_batch_job.py | 0 tests/{test_batch_pbs_pbs.py => batch/pbs/test_pbs.py} | 0 tests/{test_batch_pbs_common.py => batch/pbs/test_pbs_common.py} | 0 tests/{test_batch_pbs_job.py => batch/pbs/test_pbs_job.py} | 0 tests/{test_batch_pbs_node.py => batch/pbs/test_pbs_node.py} | 0 tests/{test_batch_pbs_queue.py => batch/pbs/test_pbs_queue.py} | 0 tests/{test_batch_slurm_slurm.py => batch/slurm/test_slurm.py} | 0 .../slurm/test_slurm_common.py} | 0 tests/{test_batch_slurm_job.py => batch/slurm/test_slurm_job.py} | 0 .../{test_batch_slurm_node.py => batch/slurm/test_slurm_node.py} | 0 .../slurm/test_slurm_queue.py} | 0 .../slurmit4i/test_slurmit4i.py} | 0 .../slurmlumi/test_slurmlumi.py} | 0 .../slurmlumi/test_slurmlumi_node.py} | 0 tests/{ => cd}/test_cd_cder.py | 0 tests/{ => cd}/test_cd_cli.py | 0 tests/{ => clear}/test_clear.py | 0 tests/{test_core_click_format.py => core/test_click_format.py} | 0 .../{test_core_command_runner.py => core/test_command_runner.py} | 0 tests/{test_core_common.py => core/test_common.py} | 0 tests/{test_core_config.py => core/test_config.py} | 0 .../{test_core_error_handlers.py => core/test_error_handlers.py} | 0 .../{test_core_field_coupling.py => core/test_field_coupling.py} | 0 tests/{test_core_intepreter.py => core/test_interpreter.py} | 0 tests/{test_core_logger.py => core/test_logger.py} | 0 tests/{test_core_logical_paths.py => core/test_logical_paths.py} | 0 tests/{test_core_navigator.py => core/test_navigator.py} | 0 tests/{test_core_operator.py => core/test_operator.py} | 0 tests/{test_core_repeater.py => core/test_repeater.py} | 0 tests/{test_core_retryer.py => core/test_retryer.py} | 0 tests/{ => go}/test_go_cli.py | 0 tests/{ => go}/test_go_goer.py | 0 tests/{ => info}/test_info_cli.py | 0 tests/{ => info}/test_info_informer.py | 0 tests/{ => info}/test_info_presenter.py | 0 tests/{ => jobs}/test_jobs_cli.py | 0 tests/{ => jobs}/test_jobs_presenter.py | 0 tests/{ => kill}/test_kill_cli.py | 0 tests/{ => kill}/test_kill_killer.py | 0 tests/{ => killall}/test_killall_cli.py | 0 tests/{ => nodes}/test_nodes_cli.py | 0 tests/{ => nodes}/test_nodes_presenter.py | 0 tests/{test_properties_depend.py => properties/test_depend.py} | 0 tests/{test_properties_info.py => properties/test_info.py} | 0 .../{test_properties_job_type.py => properties/test_job_type.py} | 0 tests/{test_properties_loop.py => properties/test_loop.py} | 0 .../test_resources.py} | 0 .../test_resubmit_host.py} | 0 tests/{test_properties_size.py => properties/test_size.py} | 0 tests/{test_properties_states.py => properties/test_states.py} | 0 .../test_transfer_mode.py} | 0 tests/{ => queues}/test_queues_cli.py | 0 tests/{ => queues}/test_queues_presenter.py | 0 tests/{ => respawn}/test_respawn_cli.py | 0 tests/{ => respawn}/test_respawn_respawner.py | 0 tests/{ => resubmit}/test_resubmit_resubmitter.py | 0 tests/{ => run}/test_run_cli.py | 0 tests/{ => run}/test_run_runner.py | 0 tests/{ => shebang}/test_shebang_cli.py | 0 tests/{ => stat}/test_stat_cli.py | 0 tests/{ => submit}/test_submit_cli.py | 0 tests/{ => submit}/test_submit_factory.py | 0 tests/{ => submit}/test_submit_parser.py | 0 tests/{ => submit}/test_submit_submitter.py | 0 tests/{ => sync}/test_sync_cli.py | 0 tests/{ => sync}/test_sync_syncer.py | 0 tests/{ => wipe}/test_wipe_cli.py | 0 tests/{ => wipe}/test_wipe_wiper.py | 0 70 files changed, 0 insertions(+), 0 deletions(-) rename tests/{ => archive}/test_archive.py (100%) rename tests/{ => batch/interface}/test_batch_interface.py (100%) rename tests/{ => batch/interface}/test_batch_job.py (100%) rename tests/{test_batch_pbs_pbs.py => batch/pbs/test_pbs.py} (100%) rename tests/{test_batch_pbs_common.py => batch/pbs/test_pbs_common.py} (100%) rename tests/{test_batch_pbs_job.py => batch/pbs/test_pbs_job.py} (100%) rename tests/{test_batch_pbs_node.py => batch/pbs/test_pbs_node.py} (100%) rename tests/{test_batch_pbs_queue.py => batch/pbs/test_pbs_queue.py} (100%) rename tests/{test_batch_slurm_slurm.py => batch/slurm/test_slurm.py} (100%) rename tests/{test_batch_slurm_common.py => batch/slurm/test_slurm_common.py} (100%) rename tests/{test_batch_slurm_job.py => batch/slurm/test_slurm_job.py} (100%) rename tests/{test_batch_slurm_node.py => batch/slurm/test_slurm_node.py} (100%) rename tests/{test_batch_slurm_queue.py => batch/slurm/test_slurm_queue.py} (100%) rename tests/{test_batch_slurmit4i_slurm.py => batch/slurmit4i/test_slurmit4i.py} (100%) rename tests/{test_batch_slurmlumi_slurm.py => batch/slurmlumi/test_slurmlumi.py} (100%) rename tests/{test_batch_slurmlumi_node.py => batch/slurmlumi/test_slurmlumi_node.py} (100%) rename tests/{ => cd}/test_cd_cder.py (100%) rename tests/{ => cd}/test_cd_cli.py (100%) rename tests/{ => clear}/test_clear.py (100%) rename tests/{test_core_click_format.py => core/test_click_format.py} (100%) rename tests/{test_core_command_runner.py => core/test_command_runner.py} (100%) rename tests/{test_core_common.py => core/test_common.py} (100%) rename tests/{test_core_config.py => core/test_config.py} (100%) rename tests/{test_core_error_handlers.py => core/test_error_handlers.py} (100%) rename tests/{test_core_field_coupling.py => core/test_field_coupling.py} (100%) rename tests/{test_core_intepreter.py => core/test_interpreter.py} (100%) rename tests/{test_core_logger.py => core/test_logger.py} (100%) rename tests/{test_core_logical_paths.py => core/test_logical_paths.py} (100%) rename tests/{test_core_navigator.py => core/test_navigator.py} (100%) rename tests/{test_core_operator.py => core/test_operator.py} (100%) rename tests/{test_core_repeater.py => core/test_repeater.py} (100%) rename tests/{test_core_retryer.py => core/test_retryer.py} (100%) rename tests/{ => go}/test_go_cli.py (100%) rename tests/{ => go}/test_go_goer.py (100%) rename tests/{ => info}/test_info_cli.py (100%) rename tests/{ => info}/test_info_informer.py (100%) rename tests/{ => info}/test_info_presenter.py (100%) rename tests/{ => jobs}/test_jobs_cli.py (100%) rename tests/{ => jobs}/test_jobs_presenter.py (100%) rename tests/{ => kill}/test_kill_cli.py (100%) rename tests/{ => kill}/test_kill_killer.py (100%) rename tests/{ => killall}/test_killall_cli.py (100%) rename tests/{ => nodes}/test_nodes_cli.py (100%) rename tests/{ => nodes}/test_nodes_presenter.py (100%) rename tests/{test_properties_depend.py => properties/test_depend.py} (100%) rename tests/{test_properties_info.py => properties/test_info.py} (100%) rename tests/{test_properties_job_type.py => properties/test_job_type.py} (100%) rename tests/{test_properties_loop.py => properties/test_loop.py} (100%) rename tests/{test_properties_resources.py => properties/test_resources.py} (100%) rename tests/{test_properties_resubmit_host.py => properties/test_resubmit_host.py} (100%) rename tests/{test_properties_size.py => properties/test_size.py} (100%) rename tests/{test_properties_states.py => properties/test_states.py} (100%) rename tests/{test_properties_transfer_mode.py => properties/test_transfer_mode.py} (100%) rename tests/{ => queues}/test_queues_cli.py (100%) rename tests/{ => queues}/test_queues_presenter.py (100%) rename tests/{ => respawn}/test_respawn_cli.py (100%) rename tests/{ => respawn}/test_respawn_respawner.py (100%) rename tests/{ => resubmit}/test_resubmit_resubmitter.py (100%) rename tests/{ => run}/test_run_cli.py (100%) rename tests/{ => run}/test_run_runner.py (100%) rename tests/{ => shebang}/test_shebang_cli.py (100%) rename tests/{ => stat}/test_stat_cli.py (100%) rename tests/{ => submit}/test_submit_cli.py (100%) rename tests/{ => submit}/test_submit_factory.py (100%) rename tests/{ => submit}/test_submit_parser.py (100%) rename tests/{ => submit}/test_submit_submitter.py (100%) rename tests/{ => sync}/test_sync_cli.py (100%) rename tests/{ => sync}/test_sync_syncer.py (100%) rename tests/{ => wipe}/test_wipe_cli.py (100%) rename tests/{ => wipe}/test_wipe_wiper.py (100%) diff --git a/tests/test_archive.py b/tests/archive/test_archive.py similarity index 100% rename from tests/test_archive.py rename to tests/archive/test_archive.py diff --git a/tests/test_batch_interface.py b/tests/batch/interface/test_batch_interface.py similarity index 100% rename from tests/test_batch_interface.py rename to tests/batch/interface/test_batch_interface.py diff --git a/tests/test_batch_job.py b/tests/batch/interface/test_batch_job.py similarity index 100% rename from tests/test_batch_job.py rename to tests/batch/interface/test_batch_job.py diff --git a/tests/test_batch_pbs_pbs.py b/tests/batch/pbs/test_pbs.py similarity index 100% rename from tests/test_batch_pbs_pbs.py rename to tests/batch/pbs/test_pbs.py diff --git a/tests/test_batch_pbs_common.py b/tests/batch/pbs/test_pbs_common.py similarity index 100% rename from tests/test_batch_pbs_common.py rename to tests/batch/pbs/test_pbs_common.py diff --git a/tests/test_batch_pbs_job.py b/tests/batch/pbs/test_pbs_job.py similarity index 100% rename from tests/test_batch_pbs_job.py rename to tests/batch/pbs/test_pbs_job.py diff --git a/tests/test_batch_pbs_node.py b/tests/batch/pbs/test_pbs_node.py similarity index 100% rename from tests/test_batch_pbs_node.py rename to tests/batch/pbs/test_pbs_node.py diff --git a/tests/test_batch_pbs_queue.py b/tests/batch/pbs/test_pbs_queue.py similarity index 100% rename from tests/test_batch_pbs_queue.py rename to tests/batch/pbs/test_pbs_queue.py diff --git a/tests/test_batch_slurm_slurm.py b/tests/batch/slurm/test_slurm.py similarity index 100% rename from tests/test_batch_slurm_slurm.py rename to tests/batch/slurm/test_slurm.py diff --git a/tests/test_batch_slurm_common.py b/tests/batch/slurm/test_slurm_common.py similarity index 100% rename from tests/test_batch_slurm_common.py rename to tests/batch/slurm/test_slurm_common.py diff --git a/tests/test_batch_slurm_job.py b/tests/batch/slurm/test_slurm_job.py similarity index 100% rename from tests/test_batch_slurm_job.py rename to tests/batch/slurm/test_slurm_job.py diff --git a/tests/test_batch_slurm_node.py b/tests/batch/slurm/test_slurm_node.py similarity index 100% rename from tests/test_batch_slurm_node.py rename to tests/batch/slurm/test_slurm_node.py diff --git a/tests/test_batch_slurm_queue.py b/tests/batch/slurm/test_slurm_queue.py similarity index 100% rename from tests/test_batch_slurm_queue.py rename to tests/batch/slurm/test_slurm_queue.py diff --git a/tests/test_batch_slurmit4i_slurm.py b/tests/batch/slurmit4i/test_slurmit4i.py similarity index 100% rename from tests/test_batch_slurmit4i_slurm.py rename to tests/batch/slurmit4i/test_slurmit4i.py diff --git a/tests/test_batch_slurmlumi_slurm.py b/tests/batch/slurmlumi/test_slurmlumi.py similarity index 100% rename from tests/test_batch_slurmlumi_slurm.py rename to tests/batch/slurmlumi/test_slurmlumi.py diff --git a/tests/test_batch_slurmlumi_node.py b/tests/batch/slurmlumi/test_slurmlumi_node.py similarity index 100% rename from tests/test_batch_slurmlumi_node.py rename to tests/batch/slurmlumi/test_slurmlumi_node.py diff --git a/tests/test_cd_cder.py b/tests/cd/test_cd_cder.py similarity index 100% rename from tests/test_cd_cder.py rename to tests/cd/test_cd_cder.py diff --git a/tests/test_cd_cli.py b/tests/cd/test_cd_cli.py similarity index 100% rename from tests/test_cd_cli.py rename to tests/cd/test_cd_cli.py diff --git a/tests/test_clear.py b/tests/clear/test_clear.py similarity index 100% rename from tests/test_clear.py rename to tests/clear/test_clear.py diff --git a/tests/test_core_click_format.py b/tests/core/test_click_format.py similarity index 100% rename from tests/test_core_click_format.py rename to tests/core/test_click_format.py diff --git a/tests/test_core_command_runner.py b/tests/core/test_command_runner.py similarity index 100% rename from tests/test_core_command_runner.py rename to tests/core/test_command_runner.py diff --git a/tests/test_core_common.py b/tests/core/test_common.py similarity index 100% rename from tests/test_core_common.py rename to tests/core/test_common.py diff --git a/tests/test_core_config.py b/tests/core/test_config.py similarity index 100% rename from tests/test_core_config.py rename to tests/core/test_config.py diff --git a/tests/test_core_error_handlers.py b/tests/core/test_error_handlers.py similarity index 100% rename from tests/test_core_error_handlers.py rename to tests/core/test_error_handlers.py diff --git a/tests/test_core_field_coupling.py b/tests/core/test_field_coupling.py similarity index 100% rename from tests/test_core_field_coupling.py rename to tests/core/test_field_coupling.py diff --git a/tests/test_core_intepreter.py b/tests/core/test_interpreter.py similarity index 100% rename from tests/test_core_intepreter.py rename to tests/core/test_interpreter.py diff --git a/tests/test_core_logger.py b/tests/core/test_logger.py similarity index 100% rename from tests/test_core_logger.py rename to tests/core/test_logger.py diff --git a/tests/test_core_logical_paths.py b/tests/core/test_logical_paths.py similarity index 100% rename from tests/test_core_logical_paths.py rename to tests/core/test_logical_paths.py diff --git a/tests/test_core_navigator.py b/tests/core/test_navigator.py similarity index 100% rename from tests/test_core_navigator.py rename to tests/core/test_navigator.py diff --git a/tests/test_core_operator.py b/tests/core/test_operator.py similarity index 100% rename from tests/test_core_operator.py rename to tests/core/test_operator.py diff --git a/tests/test_core_repeater.py b/tests/core/test_repeater.py similarity index 100% rename from tests/test_core_repeater.py rename to tests/core/test_repeater.py diff --git a/tests/test_core_retryer.py b/tests/core/test_retryer.py similarity index 100% rename from tests/test_core_retryer.py rename to tests/core/test_retryer.py diff --git a/tests/test_go_cli.py b/tests/go/test_go_cli.py similarity index 100% rename from tests/test_go_cli.py rename to tests/go/test_go_cli.py diff --git a/tests/test_go_goer.py b/tests/go/test_go_goer.py similarity index 100% rename from tests/test_go_goer.py rename to tests/go/test_go_goer.py diff --git a/tests/test_info_cli.py b/tests/info/test_info_cli.py similarity index 100% rename from tests/test_info_cli.py rename to tests/info/test_info_cli.py diff --git a/tests/test_info_informer.py b/tests/info/test_info_informer.py similarity index 100% rename from tests/test_info_informer.py rename to tests/info/test_info_informer.py diff --git a/tests/test_info_presenter.py b/tests/info/test_info_presenter.py similarity index 100% rename from tests/test_info_presenter.py rename to tests/info/test_info_presenter.py diff --git a/tests/test_jobs_cli.py b/tests/jobs/test_jobs_cli.py similarity index 100% rename from tests/test_jobs_cli.py rename to tests/jobs/test_jobs_cli.py diff --git a/tests/test_jobs_presenter.py b/tests/jobs/test_jobs_presenter.py similarity index 100% rename from tests/test_jobs_presenter.py rename to tests/jobs/test_jobs_presenter.py diff --git a/tests/test_kill_cli.py b/tests/kill/test_kill_cli.py similarity index 100% rename from tests/test_kill_cli.py rename to tests/kill/test_kill_cli.py diff --git a/tests/test_kill_killer.py b/tests/kill/test_kill_killer.py similarity index 100% rename from tests/test_kill_killer.py rename to tests/kill/test_kill_killer.py diff --git a/tests/test_killall_cli.py b/tests/killall/test_killall_cli.py similarity index 100% rename from tests/test_killall_cli.py rename to tests/killall/test_killall_cli.py diff --git a/tests/test_nodes_cli.py b/tests/nodes/test_nodes_cli.py similarity index 100% rename from tests/test_nodes_cli.py rename to tests/nodes/test_nodes_cli.py diff --git a/tests/test_nodes_presenter.py b/tests/nodes/test_nodes_presenter.py similarity index 100% rename from tests/test_nodes_presenter.py rename to tests/nodes/test_nodes_presenter.py diff --git a/tests/test_properties_depend.py b/tests/properties/test_depend.py similarity index 100% rename from tests/test_properties_depend.py rename to tests/properties/test_depend.py diff --git a/tests/test_properties_info.py b/tests/properties/test_info.py similarity index 100% rename from tests/test_properties_info.py rename to tests/properties/test_info.py diff --git a/tests/test_properties_job_type.py b/tests/properties/test_job_type.py similarity index 100% rename from tests/test_properties_job_type.py rename to tests/properties/test_job_type.py diff --git a/tests/test_properties_loop.py b/tests/properties/test_loop.py similarity index 100% rename from tests/test_properties_loop.py rename to tests/properties/test_loop.py diff --git a/tests/test_properties_resources.py b/tests/properties/test_resources.py similarity index 100% rename from tests/test_properties_resources.py rename to tests/properties/test_resources.py diff --git a/tests/test_properties_resubmit_host.py b/tests/properties/test_resubmit_host.py similarity index 100% rename from tests/test_properties_resubmit_host.py rename to tests/properties/test_resubmit_host.py diff --git a/tests/test_properties_size.py b/tests/properties/test_size.py similarity index 100% rename from tests/test_properties_size.py rename to tests/properties/test_size.py diff --git a/tests/test_properties_states.py b/tests/properties/test_states.py similarity index 100% rename from tests/test_properties_states.py rename to tests/properties/test_states.py diff --git a/tests/test_properties_transfer_mode.py b/tests/properties/test_transfer_mode.py similarity index 100% rename from tests/test_properties_transfer_mode.py rename to tests/properties/test_transfer_mode.py diff --git a/tests/test_queues_cli.py b/tests/queues/test_queues_cli.py similarity index 100% rename from tests/test_queues_cli.py rename to tests/queues/test_queues_cli.py diff --git a/tests/test_queues_presenter.py b/tests/queues/test_queues_presenter.py similarity index 100% rename from tests/test_queues_presenter.py rename to tests/queues/test_queues_presenter.py diff --git a/tests/test_respawn_cli.py b/tests/respawn/test_respawn_cli.py similarity index 100% rename from tests/test_respawn_cli.py rename to tests/respawn/test_respawn_cli.py diff --git a/tests/test_respawn_respawner.py b/tests/respawn/test_respawn_respawner.py similarity index 100% rename from tests/test_respawn_respawner.py rename to tests/respawn/test_respawn_respawner.py diff --git a/tests/test_resubmit_resubmitter.py b/tests/resubmit/test_resubmit_resubmitter.py similarity index 100% rename from tests/test_resubmit_resubmitter.py rename to tests/resubmit/test_resubmit_resubmitter.py diff --git a/tests/test_run_cli.py b/tests/run/test_run_cli.py similarity index 100% rename from tests/test_run_cli.py rename to tests/run/test_run_cli.py diff --git a/tests/test_run_runner.py b/tests/run/test_run_runner.py similarity index 100% rename from tests/test_run_runner.py rename to tests/run/test_run_runner.py diff --git a/tests/test_shebang_cli.py b/tests/shebang/test_shebang_cli.py similarity index 100% rename from tests/test_shebang_cli.py rename to tests/shebang/test_shebang_cli.py diff --git a/tests/test_stat_cli.py b/tests/stat/test_stat_cli.py similarity index 100% rename from tests/test_stat_cli.py rename to tests/stat/test_stat_cli.py diff --git a/tests/test_submit_cli.py b/tests/submit/test_submit_cli.py similarity index 100% rename from tests/test_submit_cli.py rename to tests/submit/test_submit_cli.py diff --git a/tests/test_submit_factory.py b/tests/submit/test_submit_factory.py similarity index 100% rename from tests/test_submit_factory.py rename to tests/submit/test_submit_factory.py diff --git a/tests/test_submit_parser.py b/tests/submit/test_submit_parser.py similarity index 100% rename from tests/test_submit_parser.py rename to tests/submit/test_submit_parser.py diff --git a/tests/test_submit_submitter.py b/tests/submit/test_submit_submitter.py similarity index 100% rename from tests/test_submit_submitter.py rename to tests/submit/test_submit_submitter.py diff --git a/tests/test_sync_cli.py b/tests/sync/test_sync_cli.py similarity index 100% rename from tests/test_sync_cli.py rename to tests/sync/test_sync_cli.py diff --git a/tests/test_sync_syncer.py b/tests/sync/test_sync_syncer.py similarity index 100% rename from tests/test_sync_syncer.py rename to tests/sync/test_sync_syncer.py diff --git a/tests/test_wipe_cli.py b/tests/wipe/test_wipe_cli.py similarity index 100% rename from tests/test_wipe_cli.py rename to tests/wipe/test_wipe_cli.py diff --git a/tests/test_wipe_wiper.py b/tests/wipe/test_wipe_wiper.py similarity index 100% rename from tests/test_wipe_wiper.py rename to tests/wipe/test_wipe_wiper.py From 15003052e31e56110c04ea18b727d6884f6f634a Mon Sep 17 00:00:00 2001 From: Ladme Date: Sun, 10 May 2026 09:18:15 +0200 Subject: [PATCH 38/43] Moved Interpreter into properties module --- src/qq_lib/properties/info.py | 2 +- src/qq_lib/{core => properties}/interpreter.py | 0 src/qq_lib/run/runner.py | 2 +- src/qq_lib/submit/factory.py | 2 +- src/qq_lib/submit/parser.py | 2 +- src/qq_lib/submit/submitter.py | 2 +- tests/properties/test_info.py | 2 +- tests/{core => properties}/test_interpreter.py | 6 +++--- tests/run/test_run_runner.py | 2 +- tests/submit/test_submit_factory.py | 2 +- tests/submit/test_submit_parser.py | 2 +- tests/submit/test_submit_submitter.py | 2 +- 12 files changed, 13 insertions(+), 13 deletions(-) rename src/qq_lib/{core => properties}/interpreter.py (100%) rename tests/{core => properties}/test_interpreter.py (96%) diff --git a/src/qq_lib/properties/info.py b/src/qq_lib/properties/info.py index 421b782..6789685 100644 --- a/src/qq_lib/properties/info.py +++ b/src/qq_lib/properties/info.py @@ -26,9 +26,9 @@ from qq_lib.core.common import load_yaml_dumper, load_yaml_loader from qq_lib.core.config import CFG from qq_lib.core.error import QQError -from qq_lib.core.interpreter import Interpreter 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.resubmit_host import ResubmitHost from qq_lib.properties.transfer_mode import Success, TransferMode diff --git a/src/qq_lib/core/interpreter.py b/src/qq_lib/properties/interpreter.py similarity index 100% rename from src/qq_lib/core/interpreter.py rename to src/qq_lib/properties/interpreter.py diff --git a/src/qq_lib/run/runner.py b/src/qq_lib/run/runner.py index 1b59f3d..4d71ac4 100644 --- a/src/qq_lib/run/runner.py +++ b/src/qq_lib/run/runner.py @@ -24,11 +24,11 @@ QQRunCommunicationError, QQRunFatalError, ) -from qq_lib.core.interpreter import Interpreter from qq_lib.core.logger import get_logger from qq_lib.core.logical_paths import logical_resolve from qq_lib.core.retryer import Retryer from qq_lib.info.informer import Informer +from qq_lib.properties.interpreter import Interpreter from qq_lib.properties.job_type import JobType from qq_lib.properties.states import NaiveState from qq_lib.resubmit.resubmitter import Resubmitter diff --git a/src/qq_lib/submit/factory.py b/src/qq_lib/submit/factory.py index be66298..6181d3f 100644 --- a/src/qq_lib/submit/factory.py +++ b/src/qq_lib/submit/factory.py @@ -8,8 +8,8 @@ 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.interpreter import Interpreter from qq_lib.properties.depend import Depend +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.resources import Resources diff --git a/src/qq_lib/submit/parser.py b/src/qq_lib/submit/parser.py index 3a89597..a678bcb 100644 --- a/src/qq_lib/submit/parser.py +++ b/src/qq_lib/submit/parser.py @@ -11,9 +11,9 @@ from qq_lib.batch.interface import BatchInterface from qq_lib.core.common import split_files_list, to_snake_case from qq_lib.core.error import QQError -from qq_lib.core.interpreter import Interpreter 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 from qq_lib.properties.resources import Resources from qq_lib.properties.resubmit_host import ResubmitHost diff --git a/src/qq_lib/submit/submitter.py b/src/qq_lib/submit/submitter.py index 3c52d15..a5ee2d3 100644 --- a/src/qq_lib/submit/submitter.py +++ b/src/qq_lib/submit/submitter.py @@ -17,12 +17,12 @@ ) from qq_lib.core.config import CFG from qq_lib.core.error import QQError -from qq_lib.core.interpreter import Interpreter from qq_lib.core.logger import get_logger from qq_lib.core.logical_paths import logical_resolve from qq_lib.info.informer import Informer from qq_lib.properties.depend import Depend from qq_lib.properties.info import Info +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.resources import Resources diff --git a/tests/properties/test_info.py b/tests/properties/test_info.py index 159d49e..7e4788c 100644 --- a/tests/properties/test_info.py +++ b/tests/properties/test_info.py @@ -13,8 +13,8 @@ from qq_lib.batch.pbs import PBS from qq_lib.batch.slurmit4i import SlurmIT4I from qq_lib.core.error import QQError -from qq_lib.core.interpreter import Interpreter from qq_lib.properties.info import CFG, Info +from qq_lib.properties.interpreter import Interpreter from qq_lib.properties.job_type import JobType from qq_lib.properties.resources import Resources from qq_lib.properties.resubmit_host import ExplicitHost, InputHost diff --git a/tests/core/test_interpreter.py b/tests/properties/test_interpreter.py similarity index 96% rename from tests/core/test_interpreter.py rename to tests/properties/test_interpreter.py index d941664..488111e 100644 --- a/tests/core/test_interpreter.py +++ b/tests/properties/test_interpreter.py @@ -10,11 +10,11 @@ from qq_lib.core.config import CFG from qq_lib.core.error import QQError -from qq_lib.core.interpreter import Interpreter +from qq_lib.properties.interpreter import Interpreter def test_interpreter_default_executable_only(): - with patch("qq_lib.core.interpreter.CFG") as mock_cfg: + with patch("qq_lib.properties.interpreter.CFG") as mock_cfg: mock_cfg.runner.default_interpreter = "bash" interp = Interpreter() @@ -23,7 +23,7 @@ def test_interpreter_default_executable_only(): def test_interpreter_default_with_arguments(): - with patch("qq_lib.core.interpreter.CFG") as mock_cfg: + with patch("qq_lib.properties.interpreter.CFG") as mock_cfg: mock_cfg.runner.default_interpreter = "python3 -u -O" interp = Interpreter() diff --git a/tests/run/test_run_runner.py b/tests/run/test_run_runner.py index 1250118..3543c07 100644 --- a/tests/run/test_run_runner.py +++ b/tests/run/test_run_runner.py @@ -16,7 +16,7 @@ QQRunCommunicationError, QQRunFatalError, ) -from qq_lib.core.interpreter import Interpreter +from qq_lib.properties.interpreter import Interpreter from qq_lib.properties.job_type import JobType from qq_lib.properties.states import NaiveState from qq_lib.run.runner import CFG, Runner, log_fatal_error_and_exit diff --git a/tests/submit/test_submit_factory.py b/tests/submit/test_submit_factory.py index 09f185a..4fa6cf2 100644 --- a/tests/submit/test_submit_factory.py +++ b/tests/submit/test_submit_factory.py @@ -9,8 +9,8 @@ from qq_lib.batch.interface.interface import BatchInterface from qq_lib.core.config import CFG from qq_lib.core.error import QQError -from qq_lib.core.interpreter import Interpreter from qq_lib.properties.depend import Depend +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.resources import Resources diff --git a/tests/submit/test_submit_parser.py b/tests/submit/test_submit_parser.py index e8c13af..2e7b5b2 100644 --- a/tests/submit/test_submit_parser.py +++ b/tests/submit/test_submit_parser.py @@ -12,8 +12,8 @@ from qq_lib.batch.interface.interface import BatchInterface from qq_lib.batch.pbs import PBS from qq_lib.core.error import QQError -from qq_lib.core.interpreter import Interpreter from qq_lib.properties.depend import Depend +from qq_lib.properties.interpreter import Interpreter from qq_lib.properties.job_type import JobType from qq_lib.properties.resources import Resources from qq_lib.properties.resubmit_host import ResubmitHost diff --git a/tests/submit/test_submit_submitter.py b/tests/submit/test_submit_submitter.py index b23e373..7248873 100644 --- a/tests/submit/test_submit_submitter.py +++ b/tests/submit/test_submit_submitter.py @@ -12,9 +12,9 @@ from qq_lib.batch.pbs.pbs import PBS from qq_lib.core.error import QQError -from qq_lib.core.interpreter import Interpreter from qq_lib.info.informer import Informer from qq_lib.properties.depend import Depend, DependType +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.resources import Resources From b24e4288546a3061772eaf3a9e6a5eeac8d1f425 Mon Sep 17 00:00:00 2001 From: Ladme Date: Sun, 10 May 2026 09:40:11 +0200 Subject: [PATCH 39/43] Updated help for --interpreter option --- src/qq_lib/submit/cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/qq_lib/submit/cli.py b/src/qq_lib/submit/cli.py index aa85ebf..dc07d27 100644 --- a/src/qq_lib/submit/cli.py +++ b/src/qq_lib/submit/cli.py @@ -154,7 +154,8 @@ def complete_script( "--interpreter", type=str, default=None, - help=f"Executable name or absolute path of the interpreter used to run the submitted script. Defaults to {click.style(CFG.runner.default_interpreter, bold=True)}. The interpreter must be available on the computing node.", + help=f"Executable name or absolute path of the interpreter used to run the submitted script, including options for the interpreter.\n" + f"The interpreter must be available on the computing node. Defaults to {click.style(CFG.runner.default_interpreter, bold=True)}.", ) @optgroup.option( "--batch-system", From 9ed8855a1014ef72b6c8f95c3a850f8b0706c4e7 Mon Sep 17 00:00:00 2001 From: Ladme Date: Sun, 10 May 2026 17:11:03 +0200 Subject: [PATCH 40/43] Exporting new classes --- src/qq_lib/__init__.py | 2 ++ src/qq_lib/respawn/__init__.py | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/src/qq_lib/__init__.py b/src/qq_lib/__init__.py index dfc6cfb..3f54d86 100644 --- a/src/qq_lib/__init__.py +++ b/src/qq_lib/__init__.py @@ -29,6 +29,8 @@ "nodes", "properties", "queues", + "respawn", + "resubmit", "run", "submit", "sync", diff --git a/src/qq_lib/respawn/__init__.py b/src/qq_lib/respawn/__init__.py index 42504c6..bf0f012 100644 --- a/src/qq_lib/respawn/__init__.py +++ b/src/qq_lib/respawn/__init__.py @@ -12,3 +12,9 @@ from the input directory, and submitting a fresh copy of the job. For loop jobs, the archive directory is checked for consistency before resubmission. """ + +from .respawner import Respawner + +__all__ = [ + "Respawner", +] From a9030af2bdca581ee0d8fcedc4a6a0bee5260269 Mon Sep 17 00:00:00 2001 From: Ladme Date: Sun, 10 May 2026 17:29:54 +0200 Subject: [PATCH 41/43] qq clear --dir option --- CHANGELOG.md | 3 ++ src/qq_lib/clear/cli.py | 19 ++++++--- tests/clear/test_clear.py | 81 ++++++++++++++++++++++++++++----------- 3 files changed, 75 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdbd050..27ed5fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ - `qq info`, `qq kill`, `qq sync`, `qq respawn`, `qq wipe`, and `qq go` now accept multiple job IDs as arguments. - All of these commands now also internally resolve the jobs in parallel making them much faster when dealing with a large number of jobs. +### Clearing runtime files in a specified directory +- `qq clear` now supports clearing runtime files in a directory other than the current one via the `-d`/`--dir` flag. + ### Better support for non-bash interpreters - Interpreters now support additional command-line arguments. diff --git a/src/qq_lib/clear/cli.py b/src/qq_lib/clear/cli.py index ca9e35b..6f4aa6f 100644 --- a/src/qq_lib/clear/cli.py +++ b/src/qq_lib/clear/cli.py @@ -18,30 +18,39 @@ @click.command( - short_help="Delete qq runtime files from the current directory.", - help=f"""Delete qq runtime files from the current directory. + short_help="Delete qq runtime files from a directory.", + help=f"""Delete qq runtime files from the current or specified directory. + +By default, `{CFG.binary_name} clear` clears runtime files from the current directory. Use `--dir` to specify a different directory. By default, `{CFG.binary_name} clear` removes only those files that do not correspond to an active or successfully completed job. To force deletion of all qq files regardless of job status, use the `--force` flag.""", cls=GNUHelpColorsCommand, help_options_color="bright_blue", ) +@click.option( + "-d", + "--dir", + type=str, + help="Directory from which to clear qq runtime files.", + default=None, +) @click.option( "--force", is_flag=True, help="Force deletion of all qq runtime files, even if jobs are active or successfully completed.", default=False, ) -def clear(force: bool) -> NoReturn: +def clear(dir: str | None, force: bool) -> NoReturn: """ - Delete qq runtime files in the current directory. + Delete qq runtime files in a specified directory or the current directory. Only runtime files that do **not** correspond to an active or successfully completed job are deleted, unless the `force` option is used. """ try: - clearer = Clearer(Path()) + clearer = Clearer(Path(dir) if dir else Path()) clearer.clear(force) sys.exit(0) except QQError as e: diff --git a/tests/clear/test_clear.py b/tests/clear/test_clear.py index c820014..b2f53d5 100644 --- a/tests/clear/test_clear.py +++ b/tests/clear/test_clear.py @@ -185,45 +185,80 @@ def test_clearer_clear_logs_info_when_no_files(tmp_path): assert any("Nothing to clear" in msg for msg in messages) -def test_clear_command_runs_successfully(): +def test_clear_runs_successfully(): runner = CliRunner() - dummy_clear = patch.object(Clearer, "clear") - with dummy_clear as mock_clear: + with patch("qq_lib.clear.cli.Clearer") as mock_cls: result = runner.invoke(clear, []) - assert result.exit_code == 0 - mock_clear.assert_called_once_with(False) + assert result.exit_code == 0 + mock_cls.assert_called_once_with(Path()) + mock_cls.return_value.clear.assert_called_once_with(False) -def test_clear_command_with_force_flag(): + +def test_clear_uses_current_directory_by_default(): + runner = CliRunner() + + with patch("qq_lib.clear.cli.Clearer") as mock_cls: + runner.invoke(clear, []) + + mock_cls.assert_called_once_with(Path()) + + +def test_clear_with_dir_flag(): runner = CliRunner() - dummy_clear = patch.object(Clearer, "clear") - with dummy_clear as mock_clear: + with patch("qq_lib.clear.cli.Clearer") as mock_cls: + result = runner.invoke(clear, ["-d", "/tmp/my_jobs"]) + + assert result.exit_code == 0 + mock_cls.assert_called_once_with(Path("/tmp/my_jobs")) + + +def test_clear_with_force_flag(): + runner = CliRunner() + + with patch("qq_lib.clear.cli.Clearer") as mock_cls: result = runner.invoke(clear, ["--force"]) - assert result.exit_code == 0 - mock_clear.assert_called_once_with(True) + + assert result.exit_code == 0 + mock_cls.return_value.clear.assert_called_once_with(True) -def test_clear_command_qqerror_triggers_exit_91(): +def test_clear_with_dir_and_force(): runner = CliRunner() - def raise_qqerror(force): - _ = force - raise QQError("some error") + with patch("qq_lib.clear.cli.Clearer") as mock_cls: + result = runner.invoke(clear, ["-d", "/tmp/my_jobs", "--force"]) - with patch.object(Clearer, "clear", side_effect=raise_qqerror): - result = runner.invoke(clear, []) - assert result.exit_code == CFG.exit_codes.default + assert result.exit_code == 0 + mock_cls.assert_called_once_with(Path("/tmp/my_jobs")) + mock_cls.return_value.clear.assert_called_once_with(True) -def test_clear_command_unexpected_exception_triggers_exit_99(): +def test_clear_qqerror_exits_with_default_code(): runner = CliRunner() - def raise_exception(force): - _ = force - raise RuntimeError("unexpected") + with ( + patch("qq_lib.clear.cli.Clearer") as mock_cls, + patch("qq_lib.clear.cli.logger") as mock_logger, + ): + mock_cls.return_value.clear.side_effect = QQError("some error") + result = runner.invoke(clear, []) + + assert result.exit_code == CFG.exit_codes.default + mock_logger.error.assert_called_once() + - with patch.object(Clearer, "clear", side_effect=raise_exception): +def test_clear_unexpected_exception_exits_with_unexpected_code(): + runner = CliRunner() + + with ( + patch("qq_lib.clear.cli.Clearer") as mock_cls, + patch("qq_lib.clear.cli.logger") as mock_logger, + ): + mock_cls.return_value.clear.side_effect = RuntimeError("unexpected") result = runner.invoke(clear, []) - assert result.exit_code == CFG.exit_codes.unexpected_error + + assert result.exit_code == CFG.exit_codes.unexpected_error + mock_logger.critical.assert_called_once() From 43122bbf783d846ce7f792915976740aa34e0c7b Mon Sep 17 00:00:00 2001 From: Ladme Date: Tue, 12 May 2026 09:53:30 +0200 Subject: [PATCH 42/43] Build action now runs via a self-hosted runner --- .github/workflows/release.yml | 98 +++++++++++++++++------------------ 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e0e1edf..90fb159 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,56 +9,56 @@ permissions: jobs: build-and-upload: - runs-on: ubuntu-22.04 - + runs-on: [self-hosted, Linux, X64] + steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Install dependencies and pyinstaller + run: | + uv sync --all-groups + + - name: Get version from tag + id: version + run: | + TAG="${{ github.event.release.tag_name }}" + VERSION="${TAG#v}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Build with PyInstaller + run: | + uv run pyinstaller qq.spec + + - name: Add VERSION file to package directory + run: | + echo "${{ steps.version.outputs.version }}" > dist/qq/VERSION + + - name: Update version in installation scripts + run: | + VERSION="${{ steps.version.outputs.version }}" + for script in scripts/installation_scripts/*; do + sed -i "s/__VERSION__/$VERSION/g" "$script" + done - - name: Install uv - uses: astral-sh/setup-uv@v7 - with: - enable-cache: true - - - name: Install dependencies and pyinstaller - run: | - uv sync --all-groups - - - name: Get version from tag - id: version - run: | - TAG="${{ github.event.release.tag_name }}" - VERSION="${TAG#v}" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - - name: Build with PyInstaller - run: | - uv run pyinstaller qq.spec - - - name: Add VERSION file to package directory - run: | - echo "${{ steps.version.outputs.version }}" > dist/qq/VERSION + - name: Create tarball + run: | + cd dist + tar -czf ../qq-release.tar.gz qq/ + cd .. - - name: Update version in installation scripts - run: | - VERSION="${{ steps.version.outputs.version }}" - for script in scripts/installation_scripts/*; do - sed -i "s/__VERSION__/$VERSION/g" "$script" - done + - name: Upload release assets + run: | + # upload the package + gh release upload ${{ github.event.release.tag_name }} qq-release.tar.gz - - name: Create tarball - run: | - cd dist - tar -czf ../qq-release.tar.gz qq/ - cd .. - - - name: Upload release assets - run: | - # upload the package - gh release upload ${{ github.event.release.tag_name }} qq-release.tar.gz - - # upload installation scripts - gh release upload ${{ github.event.release.tag_name }} scripts/installation_scripts/* - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + # upload installation scripts + gh release upload ${{ github.event.release.tag_name }} scripts/installation_scripts/* + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 8c1716b46f7ce950c959820f9509d48e2f332198 Mon Sep 17 00:00:00 2001 From: Ladme Date: Tue, 12 May 2026 10:01:26 +0200 Subject: [PATCH 43/43] Updated changelog --- CHANGELOG.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27ed5fd..ce958e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,10 @@ ## Version 0.11 -- Updated the installation scripts to more clearly report issues that occured during the install. - ### qq respawn -- Failed or killed jobs can be now 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. +- 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. ### Specifying multiple job IDs - `qq info`, `qq kill`, `qq sync`, `qq respawn`, `qq wipe`, and `qq go` now accept multiple job IDs as arguments. -- All of these commands now also internally resolve the jobs in parallel making them much faster when dealing with a large number of jobs. +- All of these commands now also internally resolve the jobs in parallel, making them much faster when dealing with a large number of jobs. ### Clearing runtime files in a specified directory - `qq clear` now supports clearing runtime files in a directory other than the current one via the `-d`/`--dir` flag. @@ -15,19 +13,21 @@ - Interpreters now support additional command-line arguments. ### Resubmitting with fallback hosts -- Resubmission of loop and continuous jobs now supports multiple fallback hosts. Previously, jobs were resubmitted from a single machine (the input machine or the working node, depending on the batch system). A list of hosts can now be specified via `--resubmit-from` or in the config file; they are tried in order until one succeeds, making resubmission more resilient to individual machine failures. +- Resubmission of loop and continuous jobs now supports multiple fallback hosts. Previously, jobs were resubmitted from a single machine (the input machine or the working node, depending on the batch system). A list of hosts can now be specified via `--resubmit-from` or in the config file; they are tried in order until one succeeds, making resubmission more resilient to individual machine failures. See [the manual](https://vachalab.github.io/qq-manual/resubmit_hosts.html) for more information. + +### Other changes +- Updated the installation scripts to more clearly report issues that occurred during the install. ### Internal changes - qq now uses Python 3.13 for better generics support. - Fixed type errors in qq scripts. - Refactored BatchMeta. -- Jobs can be now submitted from a remote machine. +- Jobs can now be submitted from a remote machine (only via Python API). - Submitting jobs using `Submitter.submit` is now thread-safe. -- Handling exceptions that occur when reading a configuration file. -- When reading configuration file fails, an exception is no longer raised, instead an error is reported and a default configuration is used. +- When reading a configuration file fails, an exception is no longer raised; instead, an error is reported and a default configuration is used. - `CFG` is now a frozen dataclass. - Default archive directory and archive format are now configurable. -- Changed internal representation of the interpreter specified to execute the script. +- Changed the internal representation of the interpreter specified to execute the script. ***