Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,14 @@ jobs:
runs-on: [self-hosted, Linux, X64]

steps:
- name: Clean workspace
run: |
rm -rf "$GITHUB_WORKSPACE/.git"

- uses: actions/checkout@v5
with:
fetch-depth: 0
ref: ${{ github.event.release.tag_name }}

- name: Fix git ownership
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,7 @@ htmlcov/
docs/

# Version
src/qq_lib/_version.py
src/qq_lib/_version.py

# Playground
src/_playground*.py
16 changes: 8 additions & 8 deletions .readthedocs.yaml
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
version: 2

build:
os: ubuntu-22.04
tools:
python: "3.12"
commands:
- pip install uv
- uv pip install --system pdoc
- uv pip install --system .
- pdoc src/qq_lib -d google -o $READTHEDOCS_OUTPUT/html
os: ubuntu-22.04
tools:
python: "3.13"
commands:
- pip install uv
- uv pip install --system pdoc
- uv pip install --system .
- pdoc src/qq_lib -d google -o $READTHEDOCS_OUTPUT/html
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
## Version 0.12.0
### Job collections
- **BREAKING CHANGE**: The `-s` option of `qq info` now corresponds to the `--server` option, not to the `--short` flag, for consistency with other commands. Use `--brief`/`-b` or the legacy long variant `--short` instead to get a brief summary of the job.
- The `qq killall` command has been deprecated in favor of `qq kill --all`, which has very similar behavior. It can still be used but will be completely removed in a future version of qq.
- `qq info`, `qq kill`, `qq sync`, `qq respawn`, `qq wipe`, and `qq go` can now accept one or more directories and operate on jobs within them. Job resolution is parallelized and therefore much faster than calling these commands individually. Some of these commands also accept the `--all` flag to operate on all unfinished jobs on the given batch server.
- `qq submit` now supports submitting multiple scripts at once. Submission is parallelized and therefore much faster than submitting scripts one by one.
- `qq clear` can now clear runtime files from multiple directories at once.
- *For more information about job collections, read [the manual](https://vachalab.github.io/qq-manual/job_collections.html).*

### Other changes
- If a loop job does not create any archive files for the next cycle, `qq` creates an empty `.init` file fulfilling the loop job's requirements.
- If a loop job-specific or continuous job-specific option is used either on the command line or inside the submitted script, a warning is printed to inform the user that the option will be ignored.

***

## 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.
Expand Down
4 changes: 2 additions & 2 deletions scripts/qq_scripts/gmx-eta
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

"""
Get the estimated time of a Gromacs simulation finishing.
Version qq 0.11.0.
Version qq 0.12.0.
Requires `uv`: https://docs.astral.sh/uv
"""

Expand All @@ -16,7 +16,7 @@ Requires `uv`: https://docs.astral.sh/uv
# ]
#
# [tool.uv.sources]
# qq = { git = "https://github.com/VachaLab/qq.git", tag = "v0.11.0" }
# qq = { git = "https://github.com/VachaLab/qq.git", tag = "v0.12.0" }
# ///

import argparse
Expand Down
5 changes: 3 additions & 2 deletions scripts/qq_scripts/multi-check
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@

"""
Check the state of qq jobs in multiple directories.
Version qq 0.11.0.
THIS SCRIPT IS DEPRECATED. YOU CAN USE `qq info -d <directories>` INSTEAD.
Version qq 0.12.0.
Requires `uv`: https://docs.astral.sh/uv
"""

Expand All @@ -16,7 +17,7 @@ Requires `uv`: https://docs.astral.sh/uv
# ]
#
# [tool.uv.sources]
# qq = { git = "https://github.com/VachaLab/qq.git", tag = "v0.11.0" }
# qq = { git = "https://github.com/VachaLab/qq.git", tag = "v0.12.0" }
# ///

import argparse
Expand Down
5 changes: 3 additions & 2 deletions scripts/qq_scripts/multi-kill
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@

"""
Kill qq jobs in multiple directories.
Version qq 0.11.0.
THIS SCRIPT IS DEPRECATED. YOU CAN USE `qq kill -d <directories>` INSTEAD.
Version qq 0.12.0.
Requires `uv`: https://docs.astral.sh/uv
"""

Expand All @@ -16,7 +17,7 @@ Requires `uv`: https://docs.astral.sh/uv
# ]
#
# [tool.uv.sources]
# qq = { git = "https://github.com/VachaLab/qq.git", tag = "v0.11.0" }
# qq = { git = "https://github.com/VachaLab/qq.git", tag = "v0.12.0" }
# ///

import argparse
Expand Down
7 changes: 4 additions & 3 deletions scripts/qq_scripts/multi-submit
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@

"""
Submit qq jobs from multiple directories.
Version qq 0.11.0.
THIS SCRIPT IS DEPRECATED. YOU CAN USE `qq submit <directories>/script.sh` INSTEAD.
Version qq 0.12.0.
Requires `uv`: https://docs.astral.sh/uv
"""

Expand All @@ -16,7 +17,7 @@ Requires `uv`: https://docs.astral.sh/uv
# ]
#
# [tool.uv.sources]
# qq = { git = "https://github.com/VachaLab/qq.git", tag = "v0.11.0" }
# qq = { git = "https://github.com/VachaLab/qq.git", tag = "v0.12.0" }
# ///

import argparse
Expand Down Expand Up @@ -65,7 +66,7 @@ def submit_job(base_submitter: Submitter, directory: str) -> bool:

with suppress(QQError):
# remove runtime files in the directory, if possible
clearer = Clearer(Path(directory))
clearer = Clearer([Path(directory)])
clearer.clear()

# make sure that the directory is suitable for submission
Expand Down
24 changes: 19 additions & 5 deletions src/qq_lib/archive/archiver.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def from_archive(self, dir: Path, cycle: int | None = None) -> None:
QQError: If file transfer fails.
"""
if not (
files := self._get_files(
files := self.get_files_matching_pattern(
self._archive, self._input_machine, self._archive_format, cycle, False
)
):
Expand Down Expand Up @@ -108,7 +108,11 @@ def to_archive(self, dir: Path) -> None:
Raises:
QQError: If file transfer or removal fails.
"""
if not (files := self._get_files(dir, None, self._archive_format, None, False)):
if not (
files := self.get_files_matching_pattern(
dir, None, self._archive_format, None, False
)
):
logger.debug("Nothing to archive.")
return

Expand Down Expand Up @@ -153,7 +157,7 @@ def archive_runtime_files(self, job_name: str, cycle: int) -> None:
QQError: If moving the runtime files fails.
"""
if not (
files := self._get_files(
files := self.get_files_matching_pattern(
self._input_dir,
self._input_machine,
# only use the stem of the job name, the extension will not be matched
Expand Down Expand Up @@ -183,7 +187,7 @@ def archive_runtime_files(self, job_name: str, cycle: int) -> None:
wait_seconds=CFG.archiver.retry_wait,
).run()

def _get_files(
def get_files_matching_pattern(
self,
directory: Path,
host: str | None,
Expand All @@ -205,7 +209,7 @@ def _get_files(
include_qq_files (bool): Whether to include qq runtime files. Defaults to False.

Returns:
list[Path]: A list of absolute paths to matching files.
list[Path]: A list of absolute (logical) paths to matching files.
"""
if cycle and is_printf_pattern(pattern):
try:
Expand Down Expand Up @@ -248,6 +252,16 @@ def _get_files(
if regex.search(f.stem) and f.suffix not in CFG.suffixes.all_suffixes
]

def create_init_file(self, cycle: int) -> None:
"""
Create an empty init file for the given cycle.
Used as a fallback when no valid archive file is produced, ensuring
the next iteration of the loop job can proceed normally.
Args:
cycle (int): The index of the next cycle of the loop job.
"""
Path(f"{self._archive_format % cycle}.init").touch()

@staticmethod
def _prepare_regex_pattern(pattern: str) -> re.Pattern[str]:
"""
Expand Down
23 changes: 23 additions & 0 deletions src/qq_lib/batch/interface/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,29 @@ def get_batch_job(cls, job_id: str) -> TBatchJob:
f"get_batch_job method is not implemented for {cls.__name__}"
)

@classmethod
def get_batch_jobs_from_ids(cls, job_ids: list[str]) -> list[TBatchJob]:
"""
Retrieve information about multiple jobs from the batch system.

Batch jobs should be returned in the same order as they appear in `job_ids`.
A TBatchJob object should be returned for each job id, even if the job
no longer exists or its information is unavailable.

Array jobs should NOT be expanded into their individual tasks.

The default implementation is to call `get_batch_job` for each job id.
This implementation may be inefficient for large numbers of job ids and
should be overriden by subclasses.

Args:
job_ids (list[str]): List of job identifiers.

Returns:
list[TBatchJob]: List of TBatchJob objects, one for each job id.
"""
return [cls.get_batch_job(id) for id in job_ids]

@classmethod
def get_unfinished_batch_jobs(
cls, user: str, server: str | None = None
Expand Down
4 changes: 3 additions & 1 deletion src/qq_lib/batch/pbs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ def parse_multi_pbs_dump_to_dictionaries(

data = []
block, identifier = [], None
pattern = re.compile(rf"^\s*{keyword}:\s*(.*)$") if keyword else None
pattern = pattern = (
re.compile(rf"^\s*(?:.*?{keyword}[:\s]+)(.*)$") if keyword else None
)

for line in text.splitlines():
# if the line is empty, start a new block
Expand Down
67 changes: 56 additions & 11 deletions src/qq_lib/batch/pbs/pbs.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,20 @@ def job_kill_force(cls, job_id: str) -> None:
def get_batch_job(cls, job_id: str) -> PBSJob:
return PBSJob(job_id)

@classmethod
def get_batch_jobs_from_ids(cls, job_ids: list[str]) -> list[PBSJob]:
if not job_ids:
return []

command = f"qstat -fxw {' '.join(job_ids)}"
logger.debug(command)
return cls._get_batch_jobs_using_command(
command,
include_completed=True,
include_top_level_array=True,
ignore_exit_code=True,
)

@classmethod
def get_unfinished_batch_jobs(
cls, user: str, server: str | None = None
Expand All @@ -191,31 +205,51 @@ def get_unfinished_batch_jobs(
if server:
command += f" @{server}"
logger.debug(command)
return cls._get_batch_jobs_using_command(command, include_completed=False)
return cls._get_batch_jobs_using_command(
command,
include_completed=False,
include_top_level_array=True,
ignore_exit_code=False,
)

@classmethod
def get_batch_jobs(cls, user: str, server: str | None = None) -> list[PBSJob]:
command = f"qstat -fwxtu {user}"
if server:
command += f" @{server}"
logger.debug(command)
return cls._get_batch_jobs_using_command(command, include_completed=True)
return cls._get_batch_jobs_using_command(
command,
include_completed=True,
include_top_level_array=True,
ignore_exit_code=False,
)

@classmethod
def get_all_unfinished_batch_jobs(cls, server: str | None = None) -> list[PBSJob]:
command = "qstat -fwt"
if server:
command += f" @{server}"
logger.debug(command)
return cls._get_batch_jobs_using_command(command, include_completed=False)
return cls._get_batch_jobs_using_command(
command,
include_completed=False,
include_top_level_array=True,
ignore_exit_code=False,
)

@classmethod
def get_all_batch_jobs(cls, server: str | None = None) -> list[PBSJob]:
command = "qstat -fxwt"
if server:
command += f" @{server}"
logger.debug(command)
return cls._get_batch_jobs_using_command(command, include_completed=True)
return cls._get_batch_jobs_using_command(
command,
include_completed=True,
include_top_level_array=True,
ignore_exit_code=False,
)

@classmethod
def get_queues(cls, server: str | None = None) -> list[PBSQueue]:
Expand Down Expand Up @@ -1000,7 +1034,11 @@ def _sync_directories(

@classmethod
def _get_batch_jobs_using_command(
cls, command: str, include_completed: bool
cls,
command: str,
include_completed: bool,
include_top_level_array: bool,
ignore_exit_code: bool,
) -> list[PBSJob]:
"""
Execute a shell command to retrieve information about PBS jobs and parse it.
Expand All @@ -1009,6 +1047,10 @@ def _get_batch_jobs_using_command(
command (str): The shell command to execute, typically a PBS query command.
include_completed (bool): Include both completed and uncompleted jobs.
If `False`, completed jobs are filtered out from the output.
include_top_level_array (bool): Include top-level array jobs in the output.
If `False`, top-level array jobs are filtered out from the output.
ignore_exit_code (bool): Ignore the exit code of the command.
If `False`, the command must return a zero exit code.

Returns:
list[PBSJob]: A list of `PBSJob` instances corresponding to the jobs
Expand All @@ -1020,17 +1062,21 @@ def _get_batch_jobs_using_command(
"""
...
result = subprocess.run(
["bash"],
# -oL (line-buffer stdout), -eL (line-buffer stderr)
# necessary for stdout and stderr merging
["stdbuf", "-oL", "-eL", "bash"],
input=command,
text=True,
check=False,
capture_output=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
errors="replace",
)

if result.returncode != 0:
if not ignore_exit_code and result.returncode != 0:
raise QQError(
f"Could not retrieve information about jobs: {result.stderr.strip()}."
# standard error is written to stdout
f"Could not retrieve information about jobs: {result.stdout.strip()}."
)

jobs = []
Expand All @@ -1039,8 +1085,7 @@ def _get_batch_jobs_using_command(
):
job = PBSJob.from_dict(job_id, data)

# ignore top-level array jobs
if job.is_array_job():
if not include_top_level_array and job.is_array_job():
continue

# unless all jobs are to be shown, filter out completed jobs
Expand Down
Loading