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 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 }} 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 8af7699..ce958e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,36 @@ +## Version 0.11 +### qq respawn +- Failed or killed jobs can now be easily "respawned" using `qq respawn`. When respawning a job, qq will remove the working directory of the failed job, clear all runtime files, and resubmit the job with the same parameters as before. + +### 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. + +### 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. + +### 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. 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 now be submitted from a remote machine (only via Python API). +- Submitting jobs using `Submitter.submit` is now thread-safe. +- 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 the internal representation of the interpreter specified to execute the script. + +*** + ## 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`. @@ -166,4 +199,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/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 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/).** 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/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" diff --git a/scripts/qq_scripts/gmx-eta b/scripts/qq_scripts/gmx-eta index 9ed92bb..c2fc46e 100755 --- a/scripts/qq_scripts/gmx-eta +++ b/scripts/qq_scripts/gmx-eta @@ -10,13 +10,13 @@ Requires `uv`: https://docs.astral.sh/uv """ # /// script -# requires-python = ">=3.12" +# requires-python = ">=3.13" # dependencies = [ # "qq", # ] # # [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 @@ -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 @@ -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]." ) diff --git a/scripts/qq_scripts/multi-check b/scripts/qq_scripts/multi-check index 0b9720d..3db3a20 100755 --- a/scripts/qq_scripts/multi-check +++ b/scripts/qq_scripts/multi-check @@ -5,18 +5,18 @@ """ 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", # ] # # [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 ffcf25a..27e9c5a 100755 --- a/scripts/qq_scripts/multi-kill +++ b/scripts/qq_scripts/multi-kill @@ -5,18 +5,18 @@ """ 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", # ] # # [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 abc6660..027e23c 100755 --- a/scripts/qq_scripts/multi-submit +++ b/scripts/qq_scripts/multi-submit @@ -5,18 +5,18 @@ """ 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", # ] # # [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/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/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..33bd6c0 100644 --- a/src/qq_lib/batch/interface/__init__.py +++ b/src/qq_lib/batch/interface/__init__.py @@ -18,22 +18,18 @@ 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 BatchInterface +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 b171012..a0b81d5 100644 --- a/src/qq_lib/batch/interface/interface.py +++ b/src/qq_lib/batch/interface/interface.py @@ -1,32 +1,139 @@ # 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 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 qq_lib.properties.resubmit_host import InputHost, ResubmitHost 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__) +""" +Type alias for a batch system class. +""" +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, - TBatchQueue: BatchQueueInterface, - TBatchNode: BatchNodeInterface, -](ABC): + TBatchJob: BatchJobInterface = BatchJobInterface, + TBatchQueue: BatchQueueInterface = BatchQueueInterface, + TBatchNode: BatchNodeInterface = BatchNodeInterface, +](ABC, metaclass=_BatchMeta): """ Abstract base class for batch system integrations. @@ -111,12 +218,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. @@ -126,6 +236,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. @@ -179,7 +290,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 +311,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 +330,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 +349,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 +366,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 +381,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 +396,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__}" @@ -741,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 ...`). + Get the default job resubmission hosts for this batch system. - If the resubmission fails, a QQError is raised. - - 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: @@ -797,7 +873,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 deleted file mode 100644 index e49c54f..0000000 --- a/src/qq_lib/batch/interface/meta.py +++ /dev/null @@ -1,136 +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.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: type[BatchInterface]) -> 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/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/pbs/pbs.py b/src/qq_lib/batch/pbs/pbs.py index ea93fcd..36179f6 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. """ @@ -76,11 +74,15 @@ 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) + 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 @@ -104,15 +106,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( @@ -478,25 +500,35 @@ 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, + input_dir: Path, + res: Resources, + env_vars: dict[str, str], + server: str | None, + remote_host: str | None, ) -> None: """ 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 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 @@ -504,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 @@ -516,6 +548,13 @@ 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 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." + ) @classmethod def _translate_submit( @@ -581,7 +620,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/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 diff --git a/src/qq_lib/batch/slurm/slurm.py b/src/qq_lib/batch/slurm/slurm.py index 9ec3a06..4018842 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. """ @@ -61,27 +59,51 @@ 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.") + + 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) + 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 ) - 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( @@ -433,8 +455,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/slurmit4i/slurm.py b/src/qq_lib/batch/slurmit4i/slurm.py index 199db21..b91dcd2 100644 --- a/src/qq_lib/batch/slurmit4i/slurm.py +++ b/src/qq_lib/batch/slurmit4i/slurm.py @@ -4,11 +4,9 @@ import getpass import os import shutil -import subprocess 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 @@ -16,12 +14,12 @@ 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__) -@batch_system -class SlurmIT4I(Slurm, metaclass=BatchMeta): +class SlurmIT4I(Slurm): """ Implementation of BatchInterface for Slurm on IT4I clusters. """ @@ -214,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/batch/slurmlumi/slurm.py b/src/qq_lib/batch/slurmlumi/slurm.py index 943700a..dcf34d7 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. """ @@ -48,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 @@ -56,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/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/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/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/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/common.py b/src/qq_lib/core/common.py index bdc9c85..4593fc5 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)" @@ -750,3 +747,22 @@ 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 + + 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)" diff --git a/src/qq_lib/core/config.py b/src/qq_lib/core/config.py index 951a826..98bac9c 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,20 @@ class RunnerSettings: default_interpreter: str = "bash" -@dataclass +@dataclass(frozen=True) +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(frozen=True) class ArchiverSettings: """Settings for Archiver operations.""" @@ -118,7 +131,7 @@ class ArchiverSettings: retry_wait: int = 300 -@dataclass +@dataclass(frozen=True) class GoerSettings: """Settings for Goer operations.""" @@ -127,15 +140,19 @@ class GoerSettings: wait_time: int = 5 -@dataclass +@dataclass(frozen=True) class LoopJobSettings: """Settings for qq loop jobs.""" # 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 +@dataclass(frozen=True) class JobStatusPanelSettings: """Settings for creating a job status panel.""" @@ -149,7 +166,7 @@ class JobStatusPanelSettings: title_style: str = "white bold" -@dataclass +@dataclass(frozen=True) class FullInfoPanelSettings: """Settings for creating a full info panel.""" @@ -165,7 +182,7 @@ class FullInfoPanelSettings: rule_style: str = "white" -@dataclass +@dataclass(frozen=True) class PresenterSettings: """Settings for Presenter.""" @@ -187,7 +204,7 @@ class PresenterSettings: notes_style: str = "grey50" -@dataclass +@dataclass(frozen=True) class JobsPresenterSettings: """Settings for JobsPresenter.""" @@ -225,7 +242,7 @@ class JobsPresenterSettings: sum_jobs_code: str = "Σ" -@dataclass +@dataclass(frozen=True) class QueuesPresenterSettings: """Settings for QueuesPresenter.""" @@ -265,7 +282,7 @@ class QueuesPresenterSettings: sum_jobs_code: str = "Σ" -@dataclass +@dataclass(frozen=True) class NodesPresenterSettings: """Settings for NodesPresenter.""" @@ -307,7 +324,7 @@ class NodesPresenterSettings: unavailable_node_style = "bright_red" -@dataclass +@dataclass(frozen=True) class DateFormats: """Date and time format strings.""" @@ -319,7 +336,7 @@ class DateFormats: slurm: str = "%Y-%m-%dT%H:%M:%S" -@dataclass +@dataclass(frozen=True) class ExitCodes: """Exit codes used for various errors.""" @@ -337,7 +354,7 @@ class ExitCodes: unexpected_error: int = 99 -@dataclass +@dataclass(frozen=True) class StateColors: """Color scheme for RealState display.""" @@ -371,7 +388,7 @@ class StateColors: other: str = "grey70" -@dataclass +@dataclass(frozen=True) class SizeOptions: """Options associated with the Size dataclass.""" @@ -379,7 +396,7 @@ class SizeOptions: max_rounding_error: float = 0.1 -@dataclass +@dataclass(frozen=True) class PBSOptions: """Options associated with PBS.""" @@ -387,7 +404,7 @@ class PBSOptions: scratch_dir_inner: str = "main" -@dataclass +@dataclass(frozen=True) class SlurmOptions: """Options associated with Slurm.""" @@ -395,7 +412,7 @@ class SlurmOptions: jobs_scontrol_nthreads: int = 8 -@dataclass +@dataclass(frozen=True) class SlurmIT4IOptions: """Options associated with Slurm on IT4I clusters.""" @@ -403,7 +420,7 @@ class SlurmIT4IOptions: scratch_dir_attempts: int = 3 -@dataclass +@dataclass(frozen=True) class SlurmLumiOptions: """Options associated with Slurm on LUMI.""" @@ -411,7 +428,7 @@ class SlurmLumiOptions: scratch_dir_attempts: int = 3 -@dataclass +@dataclass(frozen=True) class TransferFilesOptions: """Options associated with transferring and archiving files.""" @@ -422,7 +439,7 @@ class TransferFilesOptions: default_transfer_mode: str = "success" -@dataclass +@dataclass(frozen=True) class BatchServersOptions: """Options associated with selecting and specifying batch servers.""" @@ -446,7 +463,15 @@ class BatchServersOptions: ) -@dataclass +@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.""" @@ -454,6 +479,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) @@ -479,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" @@ -503,7 +532,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() 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 b98a414..0c90f7f 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.") @@ -220,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/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/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/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 d7aee53..3e55113 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) @@ -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/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/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/properties/info.py b/src/qq_lib/properties/info.py index 3d736b7..6789685 100644 --- a/src/qq_lib/properties/info.py +++ b/src/qq_lib/properties/info.py @@ -22,12 +22,14 @@ import yaml -from qq_lib.batch.interface import BatchInterface, 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 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 from .job_type import JobType @@ -52,7 +54,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 @@ -119,8 +121,11 @@ 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] = 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 @@ -165,7 +170,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, @@ -226,57 +231,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. @@ -318,7 +272,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 ): @@ -326,11 +280,16 @@ 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 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) @@ -372,8 +331,8 @@ 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): - init_kwargs[name] = BatchMeta.from_str(value) + elif f.type == AnyBatchClass and isinstance(value, str): + init_kwargs[name] = BatchInterface.from_str(value) # convert the job state elif f.type == NaiveState and isinstance(value, str): init_kwargs[name] = ( @@ -393,6 +352,19 @@ 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 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/properties/interpreter.py b/src/qq_lib/properties/interpreter.py new file mode 100644 index 0000000..0a949bb --- /dev/null +++ b/src/qq_lib/properties/interpreter.py @@ -0,0 +1,99 @@ +# 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/loop.py b/src/qq_lib/properties/loop.py index e2c7671..925542c 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.") @@ -156,27 +156,7 @@ 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 _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/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 new file mode 100644 index 0000000..4a0ca3e --- /dev/null +++ b/src/qq_lib/properties/resubmit_host.py @@ -0,0 +1,130 @@ +# 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 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: + """ + 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/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/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/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/respawn/__init__.py b/src/qq_lib/respawn/__init__.py new file mode 100644 index 0000000..bf0f012 --- /dev/null +++ b/src/qq_lib/respawn/__init__.py @@ -0,0 +1,20 @@ +# 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 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 .respawner import Respawner + +__all__ = [ + "Respawner", +] diff --git a/src/qq_lib/respawn/cli.py b/src/qq_lib/respawn/cli.py new file mode 100644 index 0000000..c77fa2c --- /dev/null +++ b/src/qq_lib/respawn/cli.py @@ -0,0 +1,77 @@ +# Released under MIT License. +# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab + +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.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.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 jobs, or all qq jobs in the current directory. + +{click.style("JOB_ID", fg="green")} One or more IDs of jobs to respawn. Optional. + +If no JOB_ID is specified, `{CFG.binary_name} respawn` searches for qq jobs in the current directory. + +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( + "jobs", + type=str, + metavar=click.style("JOB_ID", fg="green"), + required=False, + default=None, + nargs=-1, +) +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. + + 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..5ce9cae --- /dev/null +++ b/src/qq_lib/respawn/respawner.py @@ -0,0 +1,125 @@ +# 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.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. + + 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: + """ + 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 + + # 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) + + return Submitter( + batch_system=informer.batch_system, + queue=informer.info.queue, + account=informer.info.account, + script=self._info_file.parent / informer.info.script_name, + job_type=informer.info.job_type, + resources=informer.info.resources, + loop_info=loop_info, + exclude=informer.info.excluded_files, + include=informer.info.included_files, + # 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, + resubmit_from=informer.info.resubmit_from, + ) + + @staticmethod + def _ensure_archive_consistent(loop_info: LoopInfo) -> None: + """ + Verify that the current loop cycle matches the archive contents. + + 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() + ) != 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/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/resubmit/resubmitter.py b/src/qq_lib/resubmit/resubmitter.py new file mode 100644 index 0000000..2f5033c --- /dev/null +++ b/src/qq_lib/resubmit/resubmitter.py @@ -0,0 +1,148 @@ +# 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 = ( + 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) + + @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, + 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 _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 the list of resubmission hosts is empty. + 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." + ) + + 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}'.") + 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/__init__.py b/src/qq_lib/run/__init__.py index 4e38f9c..5e8dba0 100644 --- a/src/qq_lib/run/__init__.py +++ b/src/qq_lib/run/__init__.py @@ -7,7 +7,7 @@ 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. """ diff --git a/src/qq_lib/run/runner.py b/src/qq_lib/run/runner.py index b41b546..4d71ac4 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 ( @@ -28,8 +28,10 @@ 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 logger = get_logger(__name__, show_time=True) @@ -71,7 +73,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 @@ -196,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, @@ -415,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. @@ -716,20 +702,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/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/cli.py b/src/qq_lib/submit/cli.py index ad88551..dc07d27 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 @@ -153,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", @@ -254,7 +256,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( @@ -270,13 +290,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 627f3d6..6181d3f 100644 --- a/src/qq_lib/submit/factory.py +++ b/src/qq_lib/submit/factory.py @@ -4,13 +4,16 @@ from dataclasses import fields from pathlib import Path -from qq_lib.batch.interface import BatchInterface, BatchMeta +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 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 +from qq_lib.properties.resubmit_host import ResubmitHost from qq_lib.properties.transfer_mode import TransferMode from .parser import Parser @@ -61,19 +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(), + 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]: @@ -90,8 +96,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: """ @@ -171,10 +177,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 "" @@ -280,7 +290,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. @@ -290,12 +300,35 @@ 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, BatchSystem: AnyBatchClass) -> 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. + 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/parser.py b/src/qq_lib/submit/parser.py index 685f9bf..a678bcb 100644 --- a/src/qq_lib/submit/parser.py +++ b/src/qq_lib/submit/parser.py @@ -8,13 +8,15 @@ 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 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 from qq_lib.properties.transfer_mode import TransferMode logger = get_logger(__name__) @@ -118,7 +120,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 @@ -290,18 +292,30 @@ 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 + 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 fa43a53..a5ee2d3 100644 --- a/src/qq_lib/submit/submitter.py +++ b/src/qq_lib/submit/submitter.py @@ -4,12 +4,11 @@ import getpass import os import socket -from contextlib import chdir from datetime import datetime 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, @@ -23,9 +22,11 @@ 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 +from qq_lib.properties.resubmit_host import ResubmitHost from qq_lib.properties.states import NaiveState from qq_lib.properties.transfer_mode import TransferMode @@ -48,7 +49,7 @@ class Submitter: def __init__( self, - batch_system: type[BatchInterface], + batch_system: AnyBatchClass, queue: str, account: str | None, script: Path, @@ -60,13 +61,14 @@ 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, ): """ 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. @@ -84,8 +86,10 @@ 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. + Must only be specified for loop/continuous jobs! Raises: QQError: If the script does not exist or has an invalid shebang line. @@ -113,6 +117,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(): @@ -124,15 +129,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 +148,54 @@ 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, + remote_host=remote, + ) - # 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=socket.getfqdn(remote or ""), + input_dir=self._input_dir, + job_state=NaiveState.QUEUED, + submission_time=datetime.now(), + stdout_file=str(Path(self._job_name).with_suffix(CFG.suffixes.stdout)), + stderr_file=str(Path(self._job_name).with_suffix(CFG.suffixes.stderr)), + resources=self._resources, + loop_info=self._loop_info, + excluded_files=self._exclude, + included_files=self._include, + depend=self._depend, + account=self._account, + transfer_mode=self._transfer_mode, + server=self._server, + interpreter=self._interpreter, + resubmit_from=self._resubmit_from, ) - 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: """ @@ -267,7 +271,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 @@ -315,10 +319,14 @@ 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 + 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/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_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 91% rename from tests/test_batch_interface.py rename to tests/batch/interface/test_batch_interface.py index cea8fcb..8c4feae 100644 --- a/tests/test_batch_interface.py +++ b/tests/batch/interface/test_batch_interface.py @@ -4,12 +4,13 @@ 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.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 @@ -56,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] @@ -156,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): @@ -275,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) @@ -449,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"] ) @@ -648,14 +651,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_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 94% rename from tests/test_batch_pbs_pbs.py rename to tests/batch/pbs/test_pbs.py index ca3f26b..a1a9e32 100644 --- a/tests/test_batch_pbs_pbs.py +++ b/tests/batch/pbs/test_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) + 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) + 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) + 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) + PBS._shared_guard(Path(), Resources(work_dir=dir), env_vars, None, None) assert CFG.env_vars.shared_submit not in env_vars @@ -117,16 +127,68 @@ 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( + 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") + 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" + + +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( + Path("/path/to/local/dir"), + 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( + Path(), + Resources(work_dir="scratch_local"), + env_vars, + None, + "random.node.org", + ) 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") @@ -587,7 +649,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 +666,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 +683,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 +693,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 +710,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 +718,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 +728,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 +745,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 +757,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 +769,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 +788,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 +796,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 +806,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 +820,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 +830,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 +840,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 +850,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 +864,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 +874,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 +884,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 +898,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 +912,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 +922,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 +936,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 +950,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 +964,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 +980,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 +1003,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 +1035,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 +1067,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 +1077,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 +1087,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 +1100,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 +1134,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 @@ -1769,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_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 97% rename from tests/test_batch_slurm_slurm.py rename to tests/batch/slurm/test_slurm.py index ec4c5ac..caad18e 100644 --- a/tests/test_batch_slurm_slurm.py +++ b/tests/batch/slurm/test_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(): @@ -537,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")) @@ -746,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) + mock_guard.assert_called_once_with(script.parent, res, {}, None, None) mock_translate.assert_called_once() mock_run.assert_called_once() assert result == "56789" @@ -763,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) + 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_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 95% rename from tests/test_batch_slurm_job.py rename to tests/batch/slurm/test_slurm_job.py index 4de6d9b..40952ab 100644 --- a/tests/test_batch_slurm_job.py +++ b/tests/batch/slurm/test_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_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 90% rename from tests/test_batch_slurmit4i_slurm.py rename to tests/batch/slurmit4i/test_slurmit4i.py index 5469772..7923ec3 100644 --- a/tests/test_batch_slurmit4i_slurm.py +++ b/tests/batch/slurmit4i/test_slurmit4i.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_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 83% rename from tests/test_cd_cder.py rename to tests/cd/test_cd_cder.py index c5cdb81..8b8ca49 100644 --- a/tests/test_cd_cder.py +++ b/tests/cd/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_cd_cli.py b/tests/cd/test_cd_cli.py similarity index 78% rename from tests/test_cd_cli.py rename to tests/cd/test_cd_cli.py index 225c0fc..b8b6929 100644 --- a/tests/test_cd_cli.py +++ b/tests/cd/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_clear.py b/tests/clear/test_clear.py similarity index 76% rename from tests/test_clear.py rename to tests/clear/test_clear.py index c820014..b2f53d5 100644 --- a/tests/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() 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/core/test_command_runner.py b/tests/core/test_command_runner.py new file mode 100644 index 0000000..2242c1a --- /dev/null +++ b/tests/core/test_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_common.py b/tests/core/test_common.py similarity index 98% rename from tests/test_core_common.py rename to tests/core/test_common.py index 9b2ccf1..669f303 100644 --- a/tests/test_core_common.py +++ b/tests/core/test_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_core_config.py b/tests/core/test_config.py similarity index 97% rename from tests/test_core_config.py rename to tests/core/test_config.py index d8a9790..f364084 100644 --- a/tests/test_core_config.py +++ b/tests/core/test_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() diff --git a/tests/test_core_error_handlers.py b/tests/core/test_error_handlers.py similarity index 83% rename from tests/test_core_error_handlers.py rename to tests/core/test_error_handlers.py index cbf9fa6..9b06109 100644 --- a/tests/test_core_error_handlers.py +++ b/tests/core/test_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_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_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/go/test_go_cli.py b/tests/go/test_go_cli.py new file mode 100644 index 0000000..4eb0376 --- /dev/null +++ b/tests/go/test_go_cli.py @@ -0,0 +1,77 @@ +# Released under MIT License. +# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab + + +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +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.go.cli import _go_to_job, go, logger + + +def test_go_to_job_calls_printinfo_ensure_suitable_and_go(): + informer = MagicMock() + goer_mock = MagicMock() + + with ( + patch("qq_lib.go.cli.Goer.from_informer", return_value=goer_mock), + patch("qq_lib.go.cli.console", new=MagicMock()), + ): + _go_to_job(informer) + + goer_mock.print_info.assert_called_once() + goer_mock.ensure_suitable.assert_called_once() + goer_mock.go.assert_called_once() + + +def test_go_creates_command_runner_and_runs(): + runner = CliRunner() + + with patch("qq_lib.go.cli.CommandRunner") as mock_cls: + mock_cls.return_value.on_exception.return_value = mock_cls.return_value + mock_cls.return_value.run.side_effect = SystemExit(0) + + result = runner.invoke(go, ["111", "222"]) + + assert result.exit_code == 0 + 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_registers_exception_handlers(): + runner = CliRunner() + + with patch("qq_lib.go.cli.CommandRunner") as mock_cls: + mock_cls.return_value.on_exception.return_value = mock_cls.return_value + mock_cls.return_value.run.side_effect = SystemExit(0) + + 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_without_args_passes_empty_tuple(): + runner = CliRunner() + + with patch("qq_lib.go.cli.CommandRunner") as mock_cls: + mock_cls.return_value.on_exception.return_value = mock_cls.return_value + mock_cls.return_value.run.side_effect = SystemExit(0) + + runner.invoke(go, []) + + assert mock_cls.call_args[0][0] == () 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/info/test_info_cli.py b/tests/info/test_info_cli.py new file mode 100644 index 0000000..32d56f5 --- /dev/null +++ b/tests/info/test_info_cli.py @@ -0,0 +1,101 @@ +# Released under MIT License. +# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab + + +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +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.info.cli import _info_for_job, info, logger + + +def test_info_for_job_short_prints_short_info(): + informer_mock = MagicMock() + presenter_mock = MagicMock() + short_info_mock = MagicMock() + + presenter_mock.get_short_info.return_value = short_info_mock + + with ( + patch( + "qq_lib.info.cli.Presenter", return_value=presenter_mock + ) as presenter_cls, + patch("qq_lib.info.cli.Console") as console_cls, + ): + console_instance = console_cls.return_value + _info_for_job(informer_mock, short=True) + + presenter_cls.assert_called_once_with(informer_mock) + presenter_mock.get_short_info.assert_called_once() + console_instance.print.assert_called_once_with(short_info_mock) + + +def test_info_for_job_full_prints_full_info_panel(): + informer_mock = MagicMock() + presenter_mock = MagicMock() + panel_mock = MagicMock() + + presenter_mock.create_full_info_panel.return_value = panel_mock + + with ( + patch( + "qq_lib.info.cli.Presenter", return_value=presenter_mock + ) as presenter_cls, + patch("qq_lib.info.cli.Console") as console_cls, + ): + console_instance = console_cls.return_value + _info_for_job(informer_mock, short=False) + + presenter_cls.assert_called_once_with(informer_mock) + presenter_mock.create_full_info_panel.assert_called_once_with(console_instance) + console_instance.print.assert_called_once_with(panel_mock) + + +def test_info_creates_command_runner_and_runs(): + runner = CliRunner() + + with patch("qq_lib.info.cli.CommandRunner") as mock_cls: + mock_cls.return_value.on_exception.return_value = mock_cls.return_value + mock_cls.return_value.run.side_effect = SystemExit(0) + + result = runner.invoke(info, ["111"]) + + 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() + + +def test_info_passes_short_flag(): + 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) + + runner.invoke(info, ["--short", "111"]) + + assert mock_cls.call_args[0][3] is True + + +def test_info_registers_exception_handlers(): + 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) + + runner.invoke(info, []) + + 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_info_informer.py b/tests/info/test_info_informer.py similarity index 99% rename from tests/test_info_informer.py rename to tests/info/test_info_informer.py index 9cad8ac..c03202b 100644 --- a/tests/test_info_informer.py +++ b/tests/info/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_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 92% rename from tests/test_jobs_cli.py rename to tests/jobs/test_jobs_cli.py index cdd4e54..439385f 100644 --- a/tests/test_jobs_cli.py +++ b/tests/jobs/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_jobs_presenter.py b/tests/jobs/test_jobs_presenter.py similarity index 99% rename from tests/test_jobs_presenter.py rename to tests/jobs/test_jobs_presenter.py index 0779020..2cccb37 100644 --- a/tests/test_jobs_presenter.py +++ b/tests/jobs/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/kill/test_kill_cli.py b/tests/kill/test_kill_cli.py new file mode 100644 index 0000000..2e0e3e6 --- /dev/null +++ b/tests/kill/test_kill_cli.py @@ -0,0 +1,119 @@ +# Released under MIT License. +# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab + + +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +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.kill.cli import kill, kill_job, logger + + +def test_kill_job_force_skips_suitability_and_logs_killed(): + with ( + patch("qq_lib.kill.cli.Killer.from_informer") as mock_killer_ctor, + patch("qq_lib.kill.cli.logger.info") as mock_logger, + patch("qq_lib.kill.cli.yes_or_no_prompt") as mock_prompt, + patch("qq_lib.kill.cli.console"), + ): + mock_killer = MagicMock() + mock_killer.kill.return_value = "1234" + mock_killer_ctor.return_value = mock_killer + + kill_job(MagicMock(), force=True, yes=False) + + mock_killer.ensure_suitable.assert_not_called() + mock_killer.kill.assert_called_once_with(True) + mock_prompt.assert_not_called() + mock_logger.assert_called_once_with("Killed the job '1234'.") + + +def test_kill_job_prompts_yes_and_kills(): + with ( + patch("qq_lib.kill.cli.Killer.from_informer") as mock_killer_ctor, + patch("qq_lib.kill.cli.logger.info") as mock_logger, + patch("qq_lib.kill.cli.console"), + patch("qq_lib.kill.cli.yes_or_no_prompt", return_value=True), + ): + mock_killer = MagicMock() + mock_killer.kill.return_value = "5678" + mock_killer_ctor.return_value = mock_killer + + kill_job(MagicMock(), force=False, yes=False) + + mock_killer.ensure_suitable.assert_called_once() + mock_killer.kill.assert_called_once_with(False) + mock_logger.assert_called_once_with("Killed the job '5678'.") + + +def test_kill_job_prompts_no_and_aborts(): + with ( + patch("qq_lib.kill.cli.Killer.from_informer") as mock_killer_ctor, + patch("qq_lib.kill.cli.logger.info") as mock_logger, + patch("qq_lib.kill.cli.console"), + patch("qq_lib.kill.cli.yes_or_no_prompt", return_value=False), + ): + mock_killer = MagicMock() + mock_killer_ctor.return_value = mock_killer + + kill_job(MagicMock(), force=False, yes=False) + + mock_killer.kill.assert_not_called() + mock_logger.assert_called_once_with("Operation aborted.") + + +def test_kill_creates_command_runner_and_runs(): + runner = CliRunner() + + with patch("qq_lib.kill.cli.CommandRunner") as mock_cls: + mock_cls.return_value.on_exception.return_value = mock_cls.return_value + mock_cls.return_value.run.side_effect = SystemExit(0) + + result = runner.invoke(kill, ["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() + + 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) + + 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_registers_exception_handlers(): + runner = CliRunner() + + with patch("qq_lib.kill.cli.CommandRunner") as mock_cls: + mock_cls.return_value.on_exception.return_value = mock_cls.return_value + mock_cls.return_value.run.side_effect = SystemExit(0) + + runner.invoke(kill, []) + + 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_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 87% rename from tests/test_killall_cli.py rename to tests/killall/test_killall_cli.py index 002cea5..0e9ecc1 100644 --- a/tests/test_killall_cli.py +++ b/tests/killall/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/nodes/test_nodes_cli.py similarity index 90% rename from tests/test_nodes_cli.py rename to tests/nodes/test_nodes_cli.py index 31ce66e..05e7f9d 100644 --- a/tests/test_nodes_cli.py +++ b/tests/nodes/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_nodes_presenter.py b/tests/nodes/test_nodes_presenter.py similarity index 99% rename from tests/test_nodes_presenter.py rename to tests/nodes/test_nodes_presenter.py index 48e1db9..2404978 100644 --- a/tests/test_nodes_presenter.py +++ b/tests/nodes/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_depend.py b/tests/properties/test_depend.py similarity index 63% rename from tests/test_properties_depend.py rename to tests/properties/test_depend.py index d7722f7..10d5a99 100644 --- a/tests/test_properties_depend.py +++ b/tests/properties/test_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_properties_info.py b/tests/properties/test_info.py similarity index 72% rename from tests/test_properties_info.py rename to tests/properties/test_info.py index 4c07c24..7e4788c 100644 --- a/tests/test_properties_info.py +++ b/tests/properties/test_info.py @@ -9,19 +9,21 @@ 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 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.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 @pytest.fixture(autouse=True) def register(): - BatchMeta.register_batch_system(PBS) + _BatchMeta._registry[PBS.env_name()] = PBS @pytest.fixture @@ -52,6 +54,8 @@ 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")], + interpreter=Interpreter(executable="python", arguments=["-u"]), ) @@ -60,12 +64,14 @@ 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 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): @@ -88,6 +94,8 @@ def test_to_dict_contains_all_non_none_fields(sample_info): "excluded_files", "account", "transfer_mode", + "resubmit_from", + "interpreter", } assert expected_fields.issubset(result.keys()) @@ -107,6 +115,21 @@ 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): + 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"] + assert data["resubmit_from"] == ["input", "node01.fake.server.com"] def test_to_yaml_skips_none_fields(sample_info): @@ -116,6 +139,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): @@ -188,6 +212,8 @@ def test_from_dict_roundtrip(sample_info): "stdout_file", "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( @@ -287,122 +313,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/properties/test_interpreter.py b/tests/properties/test_interpreter.py new file mode 100644 index 0000000..488111e --- /dev/null +++ b/tests/properties/test_interpreter.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.properties.interpreter import Interpreter + + +def test_interpreter_default_executable_only(): + with patch("qq_lib.properties.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.properties.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_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 89% rename from tests/test_properties_loop.py rename to tests/properties/test_loop.py index 0f78e37..a62d51d 100644 --- a/tests/test_properties_loop.py +++ b/tests/properties/test_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,70 +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 - - -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", - ] + assert loop_info.determine_cycle_from_archive() == 10 @pytest.mark.parametrize( diff --git a/tests/test_properties_resources.py b/tests/properties/test_resources.py similarity index 91% rename from tests/test_properties_resources.py rename to tests/properties/test_resources.py index 8b5b783..9faa86d 100644 --- a/tests/test_properties_resources.py +++ b/tests/properties/test_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/properties/test_resubmit_host.py b/tests/properties/test_resubmit_host.py new file mode 100644 index 0000000..1e72d0d --- /dev/null +++ b/tests/properties/test_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_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 90% rename from tests/test_queues_cli.py rename to tests/queues/test_queues_cli.py index 24ccbd9..47268ea 100644 --- a/tests/test_queues_cli.py +++ b/tests/queues/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_queues_presenter.py b/tests/queues/test_queues_presenter.py similarity index 98% rename from tests/test_queues_presenter.py rename to tests/queues/test_queues_presenter.py index f94c6c5..c851e8e 100644 --- a/tests/test_queues_presenter.py +++ b/tests/queues/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/tests/respawn/test_respawn_cli.py b/tests/respawn/test_respawn_cli.py new file mode 100644 index 0000000..f9962c5 --- /dev/null +++ b/tests/respawn/test_respawn_cli.py @@ -0,0 +1,150 @@ +# 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.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_job, logger, respawn +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_creates_command_runner_and_runs(): + runner = CliRunner() + + with patch("qq_lib.respawn.cli.CommandRunner") as mock_cls: + mock_cls.return_value.on_exception.return_value = mock_cls.return_value + mock_cls.return_value.run.side_effect = SystemExit(0) + + result = runner.invoke(respawn, ["111"]) + + assert result.exit_code == 0 + 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_registers_exception_handlers(): + runner = CliRunner() + + with patch("qq_lib.respawn.cli.CommandRunner") as mock_cls: + mock_cls.return_value.on_exception.return_value = mock_cls.return_value + mock_cls.return_value.run.side_effect = SystemExit(0) + + runner.invoke(respawn, []) + + 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/respawn/test_respawn_respawner.py b/tests/respawn/test_respawn_respawner.py new file mode 100644 index 0000000..5d8e63a --- /dev/null +++ b/tests/respawn/test_respawn_respawner.py @@ -0,0 +1,356 @@ +# 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, + resubmit_from=informer.info.resubmit_from, + ) + + +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) diff --git a/tests/resubmit/test_resubmit_resubmitter.py b/tests/resubmit/test_resubmit_resubmitter.py new file mode 100644 index 0000000..27d83a8 --- /dev/null +++ b/tests/resubmit/test_resubmit_resubmitter.py @@ -0,0 +1,237 @@ +# 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.resubmit.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.resubmit.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, + resubmit_from=informer.info.resubmit_from, + ) + + +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.resubmit.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.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_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.resubmit.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="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) + + +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.resubmit.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.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") + 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.resubmit.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.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 + 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.resubmit.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() + 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, "_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_try.assert_called_once_with(mock_build.return_value, informer, [InputHost()]) 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 95% rename from tests/test_run_runner.py rename to tests/run/test_run_runner.py index 70ae206..3543c07 100644 --- a/tests/test_run_runner.py +++ b/tests/run/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.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 @@ -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, ): @@ -370,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(): @@ -404,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"), ): @@ -652,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() @@ -671,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() @@ -1077,6 +1087,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"), @@ -1153,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 @@ -1183,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, @@ -1649,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_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 92% rename from tests/test_stat_cli.py rename to tests/stat/test_stat_cli.py index 1e68245..cd15e72 100644 --- a/tests/test_stat_cli.py +++ b/tests/stat/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_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 80% rename from tests/test_submit_factory.py rename to tests/submit/test_submit_factory.py index ccff0cd..4fa6cf2 100644 --- a/tests/test_submit_factory.py +++ b/tests/submit/test_submit_factory.py @@ -6,13 +6,19 @@ import pytest -from qq_lib.batch.interface import BatchMeta 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.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 qq_lib.properties.resubmit_host import ( + ExplicitHost, + InputHost, + WorkHost, +) from qq_lib.properties.size import Size from qq_lib.properties.transfer_mode import ( Always, @@ -240,10 +246,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 +293,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 @@ -434,7 +467,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 +499,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() @@ -534,7 +569,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) @@ -542,12 +577,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) @@ -555,7 +590,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(): @@ -570,6 +605,113 @@ def test_submitter_factory_get_interpreter_returns_none_if_no_cli_no_parser(): assert result is None +@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() + 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 = kwargs + + 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) + + assert result == expected + + @pytest.mark.parametrize("server", [None, "fake.server.org"]) def test_submitter_factory_make_submitter_standard_job(server): mock_parser = MagicMock() @@ -582,6 +724,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 @@ -609,6 +752,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() @@ -628,21 +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_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, + 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 @@ -659,6 +807,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 @@ -688,6 +837,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, ): @@ -708,20 +860,22 @@ 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, - queue, - account, - factory._script, - JobType.LOOP, - resources, - loop_info, - excludes, - includes, - depends, - transfer, - server, - interpreter, + 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_parser.py b/tests/submit/test_submit_parser.py similarity index 93% rename from tests/test_submit_parser.py rename to tests/submit/test_submit_parser.py index d178632..2e7b5b2 100644 --- a/tests/test_submit_parser.py +++ b/tests/submit/test_submit_parser.py @@ -10,12 +10,13 @@ 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 +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 from qq_lib.properties.size import Size from qq_lib.properties.transfer_mode import TransferMode from qq_lib.submit.cli import submit @@ -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"]), @@ -324,7 +327,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") @@ -419,7 +424,38 @@ 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(): + 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 @@ -552,9 +588,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 +722,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 diff --git a/tests/test_submit_submitter.py b/tests/submit/test_submit_submitter.py similarity index 96% rename from tests/test_submit_submitter.py rename to tests/submit/test_submit_submitter.py index d82626a..7248873 100644 --- a/tests/test_submit_submitter.py +++ b/tests/submit/test_submit_submitter.py @@ -14,9 +14,11 @@ from qq_lib.core.error import QQError 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 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 @@ -40,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 @@ -56,7 +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._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): @@ -118,6 +125,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 +142,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( @@ -593,7 +602,8 @@ 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"} with ( @@ -621,6 +631,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 +657,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 +689,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 +725,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()] diff --git a/tests/sync/test_sync_cli.py b/tests/sync/test_sync_cli.py new file mode 100644 index 0000000..28e7a40 --- /dev/null +++ b/tests/sync/test_sync_cli.py @@ -0,0 +1,121 @@ +# Released under MIT License. +# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab + +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.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(): + informer = MagicMock() + syncer_mock = MagicMock() + with ( + patch("qq_lib.sync.cli.Syncer.from_informer", return_value=syncer_mock), + patch("qq_lib.sync.cli.console"), + ): + _sync_job(informer, ["a.txt", "b.txt"]) + + syncer_mock.print_info.assert_called_once() + syncer_mock.ensure_suitable.assert_called_once() + syncer_mock.sync.assert_called_once_with(["a.txt", "b.txt"]) + + +def test_sync_job_calls_sync_without_files(): + informer = MagicMock() + syncer_mock = MagicMock() + with ( + patch("qq_lib.sync.cli.Syncer.from_informer", return_value=syncer_mock), + patch("qq_lib.sync.cli.console"), + ): + _sync_job(informer, None) + + syncer_mock.sync.assert_called_once_with(None) + + +def test_split_files_returns_none_when_input_none(): + assert _split_files(None) is None + + +def test_split_files_returns_none_when_input_empty_string(): + assert _split_files("") is None + + +@pytest.mark.parametrize( + "input_str,expected", + [ + ("a.txt", ["a.txt"]), + ("a.txt b.txt", ["a.txt", "b.txt"]), + ("a.txt,b.txt", ["a.txt", "b.txt"]), + ("a.txt:b.txt", ["a.txt", "b.txt"]), + ("a.txt , b.txt:c.txt d.txt", ["a.txt", "b.txt", "c.txt", "d.txt"]), + ], +) +def test_split_files_splits_correctly(input_str, expected): + assert _split_files(input_str) == expected + + +def test_sync_creates_command_runner_and_runs(): + runner = CliRunner() + + with ( + patch("qq_lib.sync.cli.CommandRunner") as mock_cls, + patch("qq_lib.sync.cli._split_files", return_value=["a.txt", "b.txt"]), + ): + mock_cls.return_value.on_exception.return_value = mock_cls.return_value + mock_cls.return_value.run.side_effect = SystemExit(0) + + result = runner.invoke(sync, ["111", "--files", "a.txt:b.txt"]) + + assert result.exit_code == 0 + 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_without_files_passes_none(): + runner = CliRunner() + + with ( + patch("qq_lib.sync.cli.CommandRunner") as mock_cls, + patch("qq_lib.sync.cli._split_files", return_value=None) as mock_split, + ): + mock_cls.return_value.on_exception.return_value = mock_cls.return_value + mock_cls.return_value.run.side_effect = SystemExit(0) + + runner.invoke(sync, ["111"]) + + mock_split.assert_called_once_with(None) + assert mock_cls.call_args[0][3] is None + + +def test_sync_registers_exception_handlers(): + runner = CliRunner() + + with ( + patch("qq_lib.sync.cli.CommandRunner") as mock_cls, + patch("qq_lib.sync.cli._split_files", return_value=None), + ): + mock_cls.return_value.on_exception.return_value = mock_cls.return_value + mock_cls.return_value.run.side_effect = SystemExit(0) + + runner.invoke(sync, []) + + 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_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_go_cli.py b/tests/test_go_cli.py deleted file mode 100644 index a8030af..0000000 --- a/tests/test_go_cli.py +++ /dev/null @@ -1,110 +0,0 @@ -# Released under MIT License. -# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab - - -from unittest.mock import MagicMock, patch - -from click.testing import CliRunner - -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 - - -def test_go_to_job_calls_printinfo_ensure_suitable_and_go(): - informer = MagicMock() - goer_mock = MagicMock() - - with ( - patch("qq_lib.go.cli.Goer.from_informer", return_value=goer_mock), - patch("qq_lib.go.cli.console", new=MagicMock()), - ): - _go_to_job(informer) - - goer_mock.print_info.assert_called_once() - goer_mock.ensure_suitable.assert_called_once() - 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") - - 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.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"]) - - 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_catches_qqerror_and_exits_91(tmp_path): - dummy_file = tmp_path / "job.qqinfo" - dummy_file.write_text("dummy") - - 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, []) - - assert result.exit_code == CFG.exit_codes.default - mock_logger.error.assert_called_once_with(repeater_mock.run.side_effect) - - -def test_go_catches_generic_exception_and_exits_99(tmp_path): - dummy_file = tmp_path / "job.qqinfo" - dummy_file.write_text("dummy") - - 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, []) - - assert result.exit_code == CFG.exit_codes.unexpected_error - mock_logger.critical.assert_called_once() diff --git a/tests/test_info_cli.py b/tests/test_info_cli.py deleted file mode 100644 index ebfbed5..0000000 --- a/tests/test_info_cli.py +++ /dev/null @@ -1,135 +0,0 @@ -# Released under MIT License. -# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab - - -from unittest.mock import MagicMock, patch - -from click.testing import CliRunner - -from qq_lib.core.config import CFG -from qq_lib.core.error import QQError -from qq_lib.info.cli import _info_for_job, info - - -def test_info_for_job_short_prints_short_info(): - informer_mock = MagicMock() - presenter_mock = MagicMock() - short_info_mock = MagicMock() - - presenter_mock.get_short_info.return_value = short_info_mock - - with ( - patch( - "qq_lib.info.cli.Presenter", return_value=presenter_mock - ) as presenter_cls, - patch("qq_lib.info.cli.Console") as console_cls, - ): - console_instance = console_cls.return_value - _info_for_job(informer_mock, short=True) - - presenter_cls.assert_called_once_with(informer_mock) - presenter_mock.get_short_info.assert_called_once() - console_instance.print.assert_called_once_with(short_info_mock) - - -def test_info_for_job_full_prints_full_info_panel(): - informer_mock = MagicMock() - presenter_mock = MagicMock() - panel_mock = MagicMock() - - presenter_mock.create_full_info_panel.return_value = panel_mock - - with ( - patch( - "qq_lib.info.cli.Presenter", return_value=presenter_mock - ) as presenter_cls, - patch("qq_lib.info.cli.Console") as console_cls, - ): - console_instance = console_cls.return_value - _info_for_job(informer_mock, short=False) - - presenter_cls.assert_called_once_with(informer_mock) - presenter_mock.create_full_info_panel.assert_called_once_with(console_instance) - 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() - - 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() - - -def test_info_catches_qqerror_and_exits_91(tmp_path): - dummy_file = tmp_path / "job.qqinfo" - dummy_file.write_text("dummy") - - repeater_mock = MagicMock() - informer_mock = MagicMock() - repeater_mock.run.side_effect = QQError("error occurred") - - 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) - - -def test_info_catches_generic_exception_and_exits_99(tmp_path): - dummy_file = tmp_path / "job.qqinfo" - dummy_file.write_text("dummy") - - repeater_mock = MagicMock() - informer_mock = MagicMock() - repeater_mock.run.side_effect = Exception("fatal error") - - 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.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"]) - - assert result.exit_code == 0 - repeater_mock.run.assert_called_once() diff --git a/tests/test_kill_cli.py b/tests/test_kill_cli.py deleted file mode 100644 index f127e7e..0000000 --- a/tests/test_kill_cli.py +++ /dev/null @@ -1,147 +0,0 @@ -# Released under MIT License. -# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab - - -from unittest.mock import MagicMock, patch - -from click.testing import CliRunner - -from qq_lib.core.config import CFG -from qq_lib.core.error import QQError, QQNotSuitableError -from qq_lib.kill.cli import kill, kill_job - - -def test_kill_job_force_skips_suitability_and_logs_killed(): - with ( - patch("qq_lib.kill.cli.Killer.from_informer") as mock_killer_ctor, - patch("qq_lib.kill.cli.logger.info") as mock_logger, - patch("qq_lib.kill.cli.yes_or_no_prompt") as mock_prompt, - patch("qq_lib.kill.cli.console"), - ): - mock_killer = MagicMock() - mock_killer.kill.return_value = "1234" - mock_killer_ctor.return_value = mock_killer - - kill_job(MagicMock(), force=True, yes=False) - - mock_killer.ensure_suitable.assert_not_called() - mock_killer.kill.assert_called_once_with(True) - mock_prompt.assert_not_called() - mock_logger.assert_called_once_with("Killed the job '1234'.") - - -def test_kill_job_prompts_yes_and_kills(): - with ( - patch("qq_lib.kill.cli.Killer.from_informer") as mock_killer_ctor, - patch("qq_lib.kill.cli.logger.info") as mock_logger, - patch("qq_lib.kill.cli.console"), - patch("qq_lib.kill.cli.yes_or_no_prompt", return_value=True), - ): - mock_killer = MagicMock() - mock_killer.kill.return_value = "5678" - mock_killer_ctor.return_value = mock_killer - - kill_job(MagicMock(), force=False, yes=False) - - mock_killer.ensure_suitable.assert_called_once() - mock_killer.kill.assert_called_once_with(False) - mock_logger.assert_called_once_with("Killed the job '5678'.") - - -def test_kill_job_prompts_no_and_aborts(): - with ( - patch("qq_lib.kill.cli.Killer.from_informer") as mock_killer_ctor, - patch("qq_lib.kill.cli.logger.info") as mock_logger, - patch("qq_lib.kill.cli.console"), - patch("qq_lib.kill.cli.yes_or_no_prompt", return_value=False), - ): - mock_killer = MagicMock() - mock_killer_ctor.return_value = mock_killer - - kill_job(MagicMock(), force=False, yes=False) - - mock_killer.kill.assert_not_called() - 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") - - 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() - - -def test_kill_catches_qqerror_and_exits_91(tmp_path): - dummy_file = tmp_path / "info.qq" - dummy_file.write_text("dummy") - - 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, []) - - assert result.exit_code == CFG.exit_codes.default - mock_logger.error.assert_called_once_with(repeater_mock.run.side_effect) - - -def test_kill_catches_generic_exception_and_exits_99(tmp_path): - dummy_file = tmp_path / "info.qq" - dummy_file.write_text("dummy") - - 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() - - -def test_kill_with_job_id_invokes_repeater(): - runner = CliRunner() - repeater_mock = MagicMock() - informer_mock = MagicMock() - - 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() diff --git a/tests/test_sync_cli.py b/tests/test_sync_cli.py deleted file mode 100644 index d699599..0000000 --- a/tests/test_sync_cli.py +++ /dev/null @@ -1,143 +0,0 @@ -# Released under MIT License. -# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab - -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.sync.cli import _split_files, _sync_job, sync - - -def test_sync_job_calls_sync(): - informer = MagicMock() - syncer_mock = MagicMock() - with ( - patch("qq_lib.sync.cli.Syncer.from_informer", return_value=syncer_mock), - patch("qq_lib.sync.cli.console"), - ): - _sync_job(informer, ["a.txt", "b.txt"]) - - syncer_mock.print_info.assert_called_once() - syncer_mock.ensure_suitable.assert_called_once() - syncer_mock.sync.assert_called_once_with(["a.txt", "b.txt"]) - - -def test_sync_job_calls_sync_without_files(): - informer = MagicMock() - syncer_mock = MagicMock() - with ( - patch("qq_lib.sync.cli.Syncer.from_informer", return_value=syncer_mock), - patch("qq_lib.sync.cli.console"), - ): - _sync_job(informer, None) - - syncer_mock.sync.assert_called_once_with(None) - - -def test_split_files_returns_none_when_input_none(): - assert _split_files(None) is None - - -def test_split_files_returns_none_when_input_empty_string(): - assert _split_files("") is None - - -@pytest.mark.parametrize( - "input_str,expected", - [ - ("a.txt", ["a.txt"]), - ("a.txt b.txt", ["a.txt", "b.txt"]), - ("a.txt,b.txt", ["a.txt", "b.txt"]), - ("a.txt:b.txt", ["a.txt", "b.txt"]), - ("a.txt , b.txt:c.txt d.txt", ["a.txt", "b.txt", "c.txt", "d.txt"]), - ], -) -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") - - 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"), - ): - 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() - - -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"]) - - 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_sync_catches_qqerror_and_exits_91(tmp_path): - dummy_file = tmp_path / "job.qqinfo" - dummy_file.write_text("dummy") - - 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, - ): - result = runner.invoke(sync, []) - - assert result.exit_code == CFG.exit_codes.default - mock_logger.error.assert_called_once_with(repeater_mock.run.side_effect) - - -def test_sync_catches_generic_exception_and_exits_99(tmp_path): - dummy_file = tmp_path / "job.qqinfo" - dummy_file.write_text("dummy") - - 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, - ): - result = runner.invoke(sync, []) - - assert result.exit_code == CFG.exit_codes.unexpected_error - mock_logger.critical.assert_called_once() diff --git a/tests/test_wipe_cli.py b/tests/wipe/test_wipe_cli.py similarity index 52% rename from tests/test_wipe_cli.py rename to tests/wipe/test_wipe_cli.py index edd4cb7..3cf3484 100644 --- a/tests/test_wipe_cli.py +++ b/tests/wipe/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 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 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]]