diff --git a/CHANGELOG.md b/CHANGELOG.md index d91ccde..1fe4c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ -## Version 0.6.2 +## Version 0.7.0 +- Added passive support for array jobs. In the output of `qq jobs` and `qq stat`, individual sub-jobs are displayed for all array jobs. +- Added autocomplete for script name in `qq submit` and `qq shebang`. +- Some rewordings. + +*** +## Version 0.6.2 - The operation for obtaining the list of working nodes at job start is now retried potentially decreasing the number of failures on unstable systems (like Metacentrum). ## Version 0.6.1 diff --git a/pyproject.toml b/pyproject.toml index 001e7c0..f8c5011 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ dev = [ "pytest-cov>=7.0.0", "ruff>=0.13.0", "snakeviz>=2.2.2", - "ty>=0.0.1a24", + "ty>=0.0.18", ] diff --git a/src/qq_lib/batch/interface/job.py b/src/qq_lib/batch/interface/job.py index d630617..a377842 100644 --- a/src/qq_lib/batch/interface/job.py +++ b/src/qq_lib/batch/interface/job.py @@ -3,6 +3,7 @@ from abc import ABC, abstractmethod +from collections.abc import Sequence from datetime import datetime, timedelta from pathlib import Path from typing import Self @@ -331,7 +332,7 @@ def toYaml(self) -> str: pass @abstractmethod - def getSteps(self) -> list[Self]: + def getSteps(self) -> Sequence[Self]: """ Return a list of steps associated with this job. @@ -339,7 +340,7 @@ def getSteps(self) -> list[Self]: may not contain all the values that a proper BatchJobInterface contains. Returns: - list[BatchJobInterface] | None: List of job steps. An empty list if there are none. + Sequence[BatchJobInterface]: List of job steps. An empty list if there are none. """ pass @@ -352,3 +353,13 @@ def getStepId(self) -> str | None: str | None: Job step index or `None` if this is not a job step. """ pass + + @abstractmethod + def isArrayJob(self) -> bool: + """ + Return `True` if the job is a top-level array job (not a sub-job). + + Returns: + bool: `True` if the job is a top-level array job, else `False`. + """ + pass diff --git a/src/qq_lib/batch/interface/meta.py b/src/qq_lib/batch/interface/meta.py index 320fa05..36be368 100644 --- a/src/qq_lib/batch/interface/meta.py +++ b/src/qq_lib/batch/interface/meta.py @@ -29,7 +29,7 @@ def __str__(cls: type[BatchInterface]) -> str: return cls.envName() @classmethod - def register(cls, batch_cls: type[BatchInterface]) -> None: + def registerBatchSystem(cls, batch_cls: type[BatchInterface]) -> None: """ Register a batch system class in the metaclass registry. @@ -132,5 +132,5 @@ def batch_system(cls): Has to be added to every implementation of `BatchInterface`. """ - BatchMeta.register(cls) + BatchMeta.registerBatchSystem(cls) return cls diff --git a/src/qq_lib/batch/pbs/common.py b/src/qq_lib/batch/pbs/common.py index 31cae95..2fe410c 100644 --- a/src/qq_lib/batch/pbs/common.py +++ b/src/qq_lib/batch/pbs/common.py @@ -39,7 +39,8 @@ def parse_multi_pbs_dump_to_dictionaries( Args: text (str): The raw PBS dump containing information about one or more queues/jobs/nodes. - keyword (str): Keyword identifying the start of a metadata block. + keyword (str | None): Keyword identifying the start of a metadata block. + If `None`, the first line is treated as identifier. Returns: list[tuple[dict[str, str], str]]: A list of tuples, each containing: @@ -68,14 +69,14 @@ def parse_multi_pbs_dump_to_dictionaries( if not block: # extract the identifier - if keyword: - m = pattern.match(line) # ty: ignore[possibly-missing-attribute] + if pattern: + m = pattern.match(line) if not m: raise QQError( f"Invalid PBS dump format. Could not extract identifier from:\n{line}" ) identifier = m.group(1).strip() - # if keyword is not specified, used the first line as the identifier + # if keyword is not specified, use the first line as the identifier else: identifier = line.strip() block.append(line) diff --git a/src/qq_lib/batch/pbs/job.py b/src/qq_lib/batch/pbs/job.py index efdbcc2..6486e8e 100644 --- a/src/qq_lib/batch/pbs/job.py +++ b/src/qq_lib/batch/pbs/job.py @@ -3,6 +3,7 @@ import re import subprocess +from collections.abc import Sequence from datetime import datetime, timedelta from pathlib import Path from typing import Self @@ -10,7 +11,9 @@ import yaml from qq_lib.batch.interface import BatchJobInterface -from qq_lib.batch.pbs.common import parse_pbs_dump_to_dictionary +from qq_lib.batch.pbs.common import ( + parse_pbs_dump_to_dictionary, +) from qq_lib.core.common import hhmmss_to_duration, load_yaml_dumper from qq_lib.core.config import CFG from qq_lib.core.error import QQError @@ -71,6 +74,11 @@ def getState(self) -> BatchState: if not (state := self._info.get("job_state")): return BatchState.UNKNOWN + # X is used by PBS to indicate finished tasks in unfinished array jobs, + # but qq uses X to indicate failure + if state == "X": + state = "F" + # if the job is finished and the return code is not zero, return FAILED if state == "F": exit_code = self.getExitCode() @@ -289,7 +297,7 @@ def toYaml(self) -> str: to_dump, default_flow_style=False, sort_keys=False, Dumper=Dumper ) - def getSteps(self) -> list[Self]: + def getSteps(self) -> Sequence[Self]: # not available for PBS return [] @@ -297,6 +305,11 @@ def getStepId(self) -> str | None: # no job steps for PBS return None + def isArrayJob(self) -> bool: + return ( + array := self._info.get("array") + ) is not None and array.lower() == "true" + @classmethod def fromDict(cls, job_id: str, info: dict[str, str]) -> Self: """ diff --git a/src/qq_lib/batch/pbs/pbs.py b/src/qq_lib/batch/pbs/pbs.py index cb212ec..1fe320d 100644 --- a/src/qq_lib/batch/pbs/pbs.py +++ b/src/qq_lib/batch/pbs/pbs.py @@ -150,29 +150,29 @@ def jobKillForce(cls, job_id: str) -> None: @classmethod def getBatchJob(cls, job_id: str) -> PBSJob: - return PBSJob(job_id) # ty: ignore[invalid-return-type] + return PBSJob(job_id) @classmethod def getUnfinishedBatchJobs(cls, user: str) -> list[PBSJob]: - command = f"qstat -fwu {user}" + command = f"qstat -fwtu {user}" logger.debug(command) return cls._getBatchJobsUsingCommand(command) @classmethod def getBatchJobs(cls, user: str) -> list[PBSJob]: - command = f"qstat -fwxu {user}" + command = f"qstat -fwxtu {user}" logger.debug(command) return cls._getBatchJobsUsingCommand(command) @classmethod def getAllUnfinishedBatchJobs(cls) -> list[PBSJob]: - command = "qstat -fw" + command = "qstat -fwt" logger.debug(command) return cls._getBatchJobsUsingCommand(command) @classmethod def getAllBatchJobs(cls) -> list[PBSJob]: - command = "qstat -fxw" + command = "qstat -fxwt" logger.debug(command) return cls._getBatchJobsUsingCommand(command) @@ -857,6 +857,11 @@ def _getBatchJobsUsingCommand(cls, command: str) -> list[PBSJob]: for data, job_id in parse_multi_pbs_dump_to_dictionaries( result.stdout.strip(), "Job Id" ): - jobs.append(PBSJob.fromDict(job_id, data)) + # ignore top-level array jobs + job = PBSJob.fromDict(job_id, data) + if job.isArrayJob(): + continue + + jobs.append(job) return jobs diff --git a/src/qq_lib/batch/pbs/queue.py b/src/qq_lib/batch/pbs/queue.py index 4fd86a6..8aad870 100644 --- a/src/qq_lib/batch/pbs/queue.py +++ b/src/qq_lib/batch/pbs/queue.py @@ -271,13 +271,11 @@ def _setJobNumbers(self) -> None: If parsing fails or the field is missing, `_job_numbers` is set to an empty dictionary. """ if not (state_count := self._info.get("state_count")): - self._job_numbers = {} + self._job_numbers: dict[str, str] = {} + return try: - self._job_numbers = { - k: int(v) - for k, v in (p.split(":") for p in state_count.split()) # ty: ignore[possibly-missing-attribute] - } + self._job_numbers = dict(p.split(":") for p in state_count.split()) except Exception as e: logger.warning(f"Could not get job counts for queue '{self._name}': {e}.") - self._job_numbers = {} + self._job_numbers: dict[str, str] = {} diff --git a/src/qq_lib/batch/slurm/job.py b/src/qq_lib/batch/slurm/job.py index f340690..b59f768 100644 --- a/src/qq_lib/batch/slurm/job.py +++ b/src/qq_lib/batch/slurm/job.py @@ -3,6 +3,7 @@ import re import subprocess +from collections.abc import Sequence from datetime import datetime, timedelta from pathlib import Path from typing import Self @@ -307,7 +308,7 @@ def toYaml(self) -> str: self._info, default_flow_style=False, sort_keys=False, Dumper=Dumper ) - def getSteps(self) -> list[Self]: + def getSteps(self) -> Sequence[Self]: command = f"sacct -j {self._job_id} --parsable2 --format={SACCT_STEP_FIELDS}" logger.debug(command) @@ -343,6 +344,9 @@ def getStepId(self) -> str | None: except ValueError: return None + def isArrayJob(self) -> bool: + return False + @classmethod def fromDict(cls, job_id: str, info: dict[str, str]) -> Self: """ @@ -416,7 +420,7 @@ def fromSacctString(cls, string: str) -> Self: SlurmJob._assignIfAllocated(info, "AllocCPUs", "ReqCPUs", "NumCPUs") SlurmJob._assignIfAllocated(info, "AllocNodes", "ReqNodes", "NumNodes") - return SlurmJob.fromDict(info["JobId"], info) + return cls.fromDict(info["JobId"], info) @classmethod def _stepFromSacctString(cls, string: str) -> Self: @@ -448,7 +452,7 @@ def _stepFromSacctString(cls, string: str) -> Self: # other words may contain useless additional information info["JobState"] = info["JobState"].split()[0] - return SlurmJob.fromDict(info["JobId"], info) + return cls.fromDict(info["JobId"], info) def getIdsForSorting(self) -> list[int]: """ diff --git a/src/qq_lib/batch/slurm/slurm.py b/src/qq_lib/batch/slurm/slurm.py index a4e5ee7..f7aeaf6 100644 --- a/src/qq_lib/batch/slurm/slurm.py +++ b/src/qq_lib/batch/slurm/slurm.py @@ -124,18 +124,18 @@ def jobKillForce(cls, job_id: str) -> None: @classmethod def getBatchJob(cls, job_id: str) -> SlurmJob: - return SlurmJob(job_id) # ty: ignore[invalid-return-type] + return SlurmJob(job_id) @classmethod def getUnfinishedBatchJobs(cls, user: str) -> list[SlurmJob]: # get running jobs from sacct (faster than using squeue and scontrol) - command = f"sacct -u {user} --state RUNNING --allocations --noheader --parsable2 --format={SACCT_FIELDS}" + command = f"sacct -u {user} --state RUNNING --allocations --noheader --parsable2 --array --format={SACCT_FIELDS}" logger.debug(command) sacct_jobs = cls._getBatchJobsUsingSacctCommand(command) # get pending jobs using squeue - command = f'squeue -u {user} -t PENDING -h -o "%i"' + command = f'squeue -u {user} --array -t PENDING -h -o "%i"' logger.debug(command) squeue_jobs = cls._getBatchJobsUsingSqueueCommand(command) @@ -147,13 +147,13 @@ def getUnfinishedBatchJobs(cls, user: str) -> list[SlurmJob]: @classmethod def getBatchJobs(cls, user: str) -> list[SlurmJob]: # get all jobs, except pending which are not available from sacct - command = f"sacct -u {user} --allocations --noheader --parsable2 --format={SACCT_FIELDS}" + command = f"sacct -u {user} --allocations --noheader --parsable2 --array --format={SACCT_FIELDS}" logger.debug(command) sacct_jobs = cls._getBatchJobsUsingSacctCommand(command) # get pending jobs using squeue - command = f'squeue -u {user} -t PENDING -h -o "%i"' + command = f'squeue -u {user} --array -t PENDING -h -o "%i"' logger.debug(command) squeue_jobs = cls._getBatchJobsUsingSqueueCommand(command) @@ -165,13 +165,13 @@ def getBatchJobs(cls, user: str) -> list[SlurmJob]: @classmethod def getAllUnfinishedBatchJobs(cls) -> list[SlurmJob]: # get running jobs using sacct (faster than using squeue and scontrol) - command = f"sacct --state RUNNING --allusers --allocations --noheader --parsable2 --format={SACCT_FIELDS}" + command = f"sacct --state RUNNING --allusers --allocations --noheader --parsable2 --array --format={SACCT_FIELDS}" logger.debug(command) sacct_jobs = cls._getBatchJobsUsingSacctCommand(command) # get pending jobs using squeue - command = 'squeue -t PENDING -h -o "%i"' + command = 'squeue --array -t PENDING -h -o "%i"' logger.debug(command) squeue_jobs = cls._getBatchJobsUsingSqueueCommand(command) @@ -183,13 +183,13 @@ def getAllUnfinishedBatchJobs(cls) -> list[SlurmJob]: @classmethod def getAllBatchJobs(cls) -> list[SlurmJob]: # get all jobs, except pending which are not available from sacct - command = f"sacct --allusers --allocations --noheader --parsable2 --format={SACCT_FIELDS}" + command = f"sacct --allusers --allocations --noheader --parsable2 --array --format={SACCT_FIELDS}" logger.debug(command) sacct_jobs = cls._getBatchJobsUsingSacctCommand(command) # get pending jobs using squeue - command = 'squeue -t PENDING -h -o "%i"' + command = 'squeue --array -t PENDING -h -o "%i"' logger.debug(command) squeue_jobs = cls._getBatchJobsUsingSqueueCommand(command) diff --git a/src/qq_lib/clear/clearer.py b/src/qq_lib/clear/clearer.py index 1d01960..e86d15f 100644 --- a/src/qq_lib/clear/clearer.py +++ b/src/qq_lib/clear/clearer.py @@ -66,7 +66,7 @@ def clear(self, force: bool = False) -> None: ) if excluded: logger.info( - f"{len(excluded)} qq files were not safe to clear. Rerun as '{CFG.binary_name} clear --force' to clear them forcibly." + f"{len(excluded)} qq files could not be safely cleared. Rerun as '{CFG.binary_name} clear --force' to clear them forcibly." ) def _collectRunTimeFiles(self) -> set[Path]: diff --git a/src/qq_lib/core/click_format.py b/src/qq_lib/core/click_format.py index 5c5787a..7f5f5bb 100644 --- a/src/qq_lib/core/click_format.py +++ b/src/qq_lib/core/click_format.py @@ -32,7 +32,7 @@ def write_heading(self, heading: str) -> None: def write_usage( self, prog_name: str, args: str | None, prefix: str | None = None - ) -> None: + ) -> None: # ty: ignore[invalid-method-override] """Override to make Usage: header bold""" if prefix is None: prefix = "Usage:" @@ -50,7 +50,7 @@ def write_dl( rows: Sequence[tuple[str, str | None]], _col_max: int = 30, _col_spacing: int = 2, - ) -> None: + ) -> None: # ty: ignore[invalid-method-override] for term, definition in rows: colored_term = click.style(term, fg=self.options_color, bold=True) self.write(f" {colored_term}\n") diff --git a/src/qq_lib/core/common.py b/src/qq_lib/core/common.py index d81dfc2..15eed8d 100644 --- a/src/qq_lib/core/common.py +++ b/src/qq_lib/core/common.py @@ -30,7 +30,7 @@ def load_yaml_dumper() -> type[yaml.Dumper]: """Return the fastest available YAML dumper (CDumper if possible).""" try: - from yaml import CDumper as Dumper # type: ignore[attr-defined] + from yaml import CDumper as Dumper logger.debug("Loaded YAML CDumper.") except ImportError: @@ -45,7 +45,7 @@ def load_yaml_loader() -> type[yaml.SafeLoader]: """Return the fastest available safe YAML loader (CSafeLoader if possible).""" try: from yaml import ( - CSafeLoader as SafeLoader, # ty: ignore[possibly-missing-import] + CSafeLoader as SafeLoader, ) logger.debug("Loaded YAML CLoader.") diff --git a/src/qq_lib/core/repeater.py b/src/qq_lib/core/repeater.py index f0a29df..b2e67b4 100644 --- a/src/qq_lib/core/repeater.py +++ b/src/qq_lib/core/repeater.py @@ -65,7 +65,7 @@ def onException( - BaseException: The caught exception instance. - Repeater: Reference to this `Repeater` instance. """ - self._handlers[exc_type] = handler # ty: ignore[invalid-assignment] + self._handlers[exc_type] = handler def run(self) -> None: """ @@ -87,4 +87,4 @@ def run(self) -> None: except tuple(self._handlers.keys()) as e: self.encountered_errors[i] = e handler = self._handlers[type(e)] - handler(e, self) # ty: ignore[invalid-argument-type] + handler(e, self) diff --git a/src/qq_lib/go/goer.py b/src/qq_lib/go/goer.py index 3ae7b10..375959c 100644 --- a/src/qq_lib/go/goer.py +++ b/src/qq_lib/go/goer.py @@ -83,6 +83,9 @@ def go(self) -> None: "Host ('main_node') or working directory ('work_dir') are not defined." ) + # hint for type checker + # work_dir and main_node must be set - we check that in self.hasDestination + assert self._work_dir and self._main_node logger.info(f"Navigating to '{str(self._work_dir)}' on '{self._main_node}'.") self._batch_system.navigateToDestination(self._main_node, self._work_dir) diff --git a/src/qq_lib/info/informer.py b/src/qq_lib/info/informer.py index 3237785..8e416fa 100644 --- a/src/qq_lib/info/informer.py +++ b/src/qq_lib/info/informer.py @@ -85,7 +85,7 @@ def fromJobId(cls, job_id: str) -> Self: if batch_job.isEmpty(): raise QQError(f"Job '{job_id}' does not exist.") - return Informer.fromBatchJob(batch_job) + return cls.fromBatchJob(batch_job) @classmethod def fromBatchJob(cls, batch_job: BatchJobInterface) -> Self: @@ -109,7 +109,7 @@ def fromBatchJob(cls, batch_job: BatchJobInterface) -> Self: if not (path := batch_job.getInfoFile()): raise QQError(f"Job '{batch_job.getId()}' is not a valid qq job.") - informer = Informer.fromFile(path) + informer = cls.fromFile(path) # check that the loaded info file actually corresponds to the batch job's ID if not informer.matchesJob(batch_job.getId()): diff --git a/src/qq_lib/info/presenter.py b/src/qq_lib/info/presenter.py index b7149a1..143b605 100644 --- a/src/qq_lib/info/presenter.py +++ b/src/qq_lib/info/presenter.py @@ -1,6 +1,7 @@ # Released under MIT License. # Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab +from collections.abc import Sequence from datetime import datetime from rich.align import Align @@ -362,7 +363,7 @@ def _createJobStatusTable( return table - def _createJobStepsTable(self, steps: list[BatchJobInterface]) -> Table: + def _createJobStepsTable(self, steps: Sequence[BatchJobInterface]) -> Table: """ Create a formatted Rich table displaying job step information. @@ -370,7 +371,7 @@ def _createJobStepsTable(self, steps: list[BatchJobInterface]) -> Table: to be used within full-info job panels. Args: - steps: A list of batch-system step objects belonging to the job. + steps (Sequence[BatchJobInterface]): A list of batch-system step objects belonging to the job. Returns: Table: A Rich table containing the formatted step information. diff --git a/src/qq_lib/jobs/presenter.py b/src/qq_lib/jobs/presenter.py index 0f76b3b..819704b 100644 --- a/src/qq_lib/jobs/presenter.py +++ b/src/qq_lib/jobs/presenter.py @@ -214,7 +214,7 @@ def _createJobRow(self, job: BatchJobInterface, headers: list[str]) -> list[str] self._stats.addJob(state, cpus, gpus, nodes) # build the row - row_data = { + row_data: dict[str, str] = { "S": JobsPresenter._color(state.toCode(), state.color), "Job ID": JobsPresenter._mainColor( JobsPresenter._shortenJobId(job.getId()) @@ -233,7 +233,7 @@ def _createJobRow(self, job: BatchJobInterface, headers: list[str]) -> list[str] "Node": JobsPresenter._formatNodesOrComment(state, job), "%CPU": JobsPresenter._formatUtilCPU(job.getUtilCPU()), "%Mem": JobsPresenter._formatUtilMem(job.getUtilMem()), - "Exit": JobsPresenter._formatExitCode(job, state) if self._all else None, + "Exit": JobsPresenter._formatExitCode(job, state) if self._all else "", } return [row_data[header] for header in headers if header in row_data] diff --git a/src/qq_lib/properties/depend.py b/src/qq_lib/properties/depend.py index 374d6d0..a9bf3e9 100644 --- a/src/qq_lib/properties/depend.py +++ b/src/qq_lib/properties/depend.py @@ -38,7 +38,7 @@ class DependType(Enum): AFTER_COMPLETION = 3 @classmethod - def fromStr(cls, string: str) -> Self: + def fromStr(cls, string: str) -> "DependType": """ Convert a dependency string keyword to a `DependType`. diff --git a/src/qq_lib/properties/info.py b/src/qq_lib/properties/info.py index bca0771..c0b421c 100644 --- a/src/qq_lib/properties/info.py +++ b/src/qq_lib/properties/info.py @@ -333,8 +333,8 @@ def _fromDict(cls, data: dict[str, object]) -> Self: # convert optional loop job info elif f.type == LoopInfo | None and isinstance(value, dict): # 'archive' must be converted to Path - init_kwargs[name] = LoopInfo( # ty: ignore[missing-argument] - **{k: Path(v) if k == "archive" else v for k, v in value.items()} + init_kwargs[name] = LoopInfo( + **{k: Path(v) if k == "archive" else v for k, v in value.items()} # ty: ignore[invalid-argument-type] ) # convert resources elif f.type == Resources: @@ -349,7 +349,7 @@ def _fromDict(cls, data: dict[str, object]) -> Self: ) # convert paths (incl. optional paths) elif f.type == Path or f.type == Path | None: - init_kwargs[name] = Path(value) + init_kwargs[name] = Path(value) # ty: ignore[invalid-argument-type] # convert the list of excluded paths elif f.type == list[Path] and isinstance(value, list): init_kwargs[name] = [ diff --git a/src/qq_lib/properties/states.py b/src/qq_lib/properties/states.py index e5a072f..3b96c50 100644 --- a/src/qq_lib/properties/states.py +++ b/src/qq_lib/properties/states.py @@ -14,7 +14,6 @@ """ from enum import Enum -from typing import Self from qq_lib.core.config import CFG from qq_lib.core.logger import get_logger @@ -44,7 +43,7 @@ def __str__(self) -> str: return self.name.lower() @classmethod - def fromStr(cls, s: str) -> Self: + def fromStr(cls, s: str) -> "NaiveState": """ Convert a string to the corresponding NaiveState enum variant. @@ -106,7 +105,7 @@ def _codeToState(cls) -> dict[str, str]: } @classmethod - def fromCode(cls, code: str) -> Self: + def fromCode(cls, code: str) -> "BatchState": """ Convert a one-letter batch system code to a BatchState enum variant. @@ -186,7 +185,9 @@ def __str__(self) -> str: return self.name.lower().replace("_", " ") @classmethod - def fromStates(cls, naive_state: NaiveState, batch_state: BatchState) -> Self: + def fromStates( + cls, naive_state: NaiveState, batch_state: BatchState + ) -> "RealState": """ Determine the RealState of a job based on its NaiveState and BatchState. diff --git a/src/qq_lib/qq.py b/src/qq_lib/qq.py index 6e35228..59cb850 100644 --- a/src/qq_lib/qq.py +++ b/src/qq_lib/qq.py @@ -47,6 +47,8 @@ def cli(ctx: click.Context, version: bool): qq is a wrapper around batch scheduling systems, simplifying job submission and management. For detailed information, visit: https://vachalab.github.io/qq-manual. + + To report issues and suggest improvements, visit: https://github.com/VachaLab/qq/issues. """ if version: print(__version__) diff --git a/src/qq_lib/shebang/cli.py b/src/qq_lib/shebang/cli.py index 8723298..861d8ca 100644 --- a/src/qq_lib/shebang/cli.py +++ b/src/qq_lib/shebang/cli.py @@ -30,7 +30,9 @@ ) @click.argument( "script", - type=str, + type=click.Path( + exists=True, file_okay=True, dir_okay=False, readable=True, path_type=str + ), metavar=click.style("SCRIPT", fg="green"), required=False, default=None, diff --git a/src/qq_lib/submit/cli.py b/src/qq_lib/submit/cli.py index 37753c3..30ed37d 100644 --- a/src/qq_lib/submit/cli.py +++ b/src/qq_lib/submit/cli.py @@ -33,7 +33,13 @@ cls=GNUHelpColorsCommand, help_options_color="bright_blue", ) -@click.argument("script", type=str, metavar=click.style("SCRIPT", fg="green")) +@click.argument( + "script", + type=click.Path( + exists=True, file_okay=True, dir_okay=False, readable=True, path_type=str + ), + metavar=click.style("SCRIPT", fg="green"), +) @optgroup.group(f"{click.style('General settings', fg='yellow')}") @optgroup.option( "--queue", diff --git a/src/qq_lib/submit/parser.py b/src/qq_lib/submit/parser.py index 3c225b6..4a76795 100644 --- a/src/qq_lib/submit/parser.py +++ b/src/qq_lib/submit/parser.py @@ -34,7 +34,11 @@ def __init__(self, script: Path, params: list[Parameter]): valid options. Only `GroupedOption` names are considered. """ self._script = script - self._known_options = {p.name for p in params if isinstance(p, GroupedOption)} + self._known_options = { + p.name + for p in params + if isinstance(p, GroupedOption) and p.name is not None + } logger.debug( f"Known options for Parser: {self._known_options} ({len(self._known_options)} options)." ) @@ -149,7 +153,7 @@ def getResources(self) -> Resources: """ field_names = {f.name for f in fields(Resources)} # only select fields that are part of Resources - return Resources(**{k: v for k, v in self._options.items() if k in field_names}) + return Resources(**{k: v for k, v in self._options.items() if k in field_names}) # ty: ignore[invalid-argument-type] def getExclude(self) -> list[Path]: """ @@ -204,8 +208,8 @@ def getArchive(self) -> Path | None: Returns: Path | None: Archive directory path, or None if not set. """ - if archive := self._options.get("archive"): - return Path(archive) + if (archive := self._options.get("archive")) is not None: + return Path(archive) # ty: ignore[invalid-argument-type] return None diff --git a/src/qq_lib/sync/syncer.py b/src/qq_lib/sync/syncer.py index 9db2143..b07dc80 100644 --- a/src/qq_lib/sync/syncer.py +++ b/src/qq_lib/sync/syncer.py @@ -62,6 +62,10 @@ def sync(self, files: list[str] | None = None) -> None: "Host ('main_node') or working directory ('work_dir') are not defined." ) + # hint for type checker + # work_dir and main_node must be set - we check that in self.hasDestination + assert self._work_dir and self._main_node + if files: logger.info( f"Fetching file{'s' if len(files) > 1 else ''} '{' '.join(files)}' from job's working directory to input directory." @@ -71,7 +75,7 @@ def sync(self, files: list[str] | None = None) -> None: self._informer.info.input_dir, self._main_node, None, - [self._work_dir / x for x in files], # ty: ignore[unsupported-operator] + [self._work_dir / x for x in files], ) else: logger.info( diff --git a/src/qq_lib/wipe/wiper.py b/src/qq_lib/wipe/wiper.py index 0f26e53..756c72b 100644 --- a/src/qq_lib/wipe/wiper.py +++ b/src/qq_lib/wipe/wiper.py @@ -65,6 +65,10 @@ def wipe(self) -> str: "Host ('main_node') or working directory ('work_dir') are not defined." ) + # hint for type checker + # work_dir and main_node must be set - we check that in self.hasDestination + assert self._work_dir and self._main_node + # we cannot delete the input directory even if the `--force` flag is used if self._workDirIsInputDir(): raise QQError( diff --git a/tests/test_batch_interface.py b/tests/test_batch_interface.py index 649efeb..50049ba 100644 --- a/tests/test_batch_interface.py +++ b/tests/test_batch_interface.py @@ -56,7 +56,7 @@ def test_navigate_same_host_error(): def test_guess_pbs(): BatchMeta._registry.clear() - BatchMeta.register(PBS) + BatchMeta.registerBatchSystem(PBS) with patch.object(PBS, "isAvailable", return_value=True): assert BatchMeta.guess() is PBS @@ -76,7 +76,7 @@ def test_guess_empty_registry(): def test_from_str_success(): BatchMeta._registry.clear() - BatchMeta.register(PBS) + BatchMeta.registerBatchSystem(PBS) assert BatchMeta.fromStr("PBS") is PBS @@ -97,7 +97,7 @@ def test_from_str_none_registered(): def test_env_var_or_guess_from_env_var_returns_value(monkeypatch): BatchMeta._registry.clear() - BatchMeta.register(PBS) + BatchMeta.registerBatchSystem(PBS) monkeypatch.setenv(CFG.env_vars.batch_system, "PBS") assert BatchMeta.fromEnvVarOrGuess() is PBS @@ -105,7 +105,7 @@ def test_env_var_or_guess_from_env_var_returns_value(monkeypatch): def test_env_var_or_guess_from_env_var_not_set_calls_guess(): BatchMeta._registry.clear() - BatchMeta.register(PBS) + BatchMeta.registerBatchSystem(PBS) if CFG.env_vars.batch_system in os.environ: del os.environ[CFG.env_vars.batch_system] @@ -126,7 +126,7 @@ def test_from_env_var_not_set_calls_guess(): def test_obtain_with_name_registered(): BatchMeta._registry.clear() - BatchMeta.register(PBS) + BatchMeta.registerBatchSystem(PBS) assert BatchMeta.obtain("PBS") is PBS @@ -140,7 +140,7 @@ def test_obtain_with_name_not_registered(): def test_obtain_without_name_env_var(monkeypatch): BatchMeta._registry.clear() - BatchMeta.register(PBS) + BatchMeta.registerBatchSystem(PBS) monkeypatch.setenv(CFG.env_vars.batch_system, "PBS") assert BatchMeta.obtain(None) is PBS diff --git a/tests/test_batch_pbs_pbs.py b/tests/test_batch_pbs_pbs.py index 8ab191d..366b321 100644 --- a/tests/test_batch_pbs_pbs.py +++ b/tests/test_batch_pbs_pbs.py @@ -1167,14 +1167,14 @@ def test_get_jobs_info_using_command_success(sample_multi_dump_file): "123457.fake-cluster.example.com", "123458.fake-cluster.example.com", ] - assert [job._job_id for job in jobs] == expected_ids # ty: ignore[unresolved-attribute] + assert [job._job_id for job in jobs] == expected_ids - assert [job._info["Job_Name"] for job in jobs] == [ # ty: ignore[unresolved-attribute] + assert [job._info["Job_Name"] for job in jobs] == [ "example_job_1", "example_job_2", "example_job_3", ] - assert [job._info["job_state"] for job in jobs] == [ # ty: ignore[unresolved-attribute] + assert [job._info["job_state"] for job in jobs] == [ "R", "Q", "H", diff --git a/tests/test_batch_pbs_queue.py b/tests/test_batch_pbs_queue.py index 5cbfef2..0845d7e 100644 --- a/tests/test_batch_pbs_queue.py +++ b/tests/test_batch_pbs_queue.py @@ -136,8 +136,8 @@ def test_pbsqueue_get_name(): def test_pbsqueue_get_priority_returns_value(): queue = PBSQueue.__new__(PBSQueue) - queue._info = {"Priority": 5} - assert queue.getPriority() == 5 + queue._info = {"Priority": "5"} + assert queue.getPriority() == "5" def test_pbsqueue_get_priority_returns_none(): @@ -148,7 +148,7 @@ def test_pbsqueue_get_priority_returns_none(): def test_pbsqueue_get_total_jobs_with_value(): queue = PBSQueue.__new__(PBSQueue) - queue._info = {"total_jobs": 10} + queue._info = {"total_jobs": "10"} assert queue.getTotalJobs() == 10 @@ -160,7 +160,7 @@ def test_pbsqueue_get_total_jobs_default_none(): def test_pbsqueue_get_running_jobs_with_value(): queue = PBSQueue.__new__(PBSQueue) - queue._job_numbers = {"Running": 4} + queue._job_numbers = {"Running": "4"} assert queue.getRunningJobs() == 4 @@ -172,7 +172,7 @@ def test_pbsqueue_get_running_jobs_default_none(): def test_pbsqueue_get_queued_jobs_with_value(): queue = PBSQueue.__new__(PBSQueue) - queue._job_numbers = {"Queued": 7} + queue._job_numbers = {"Queued": "7"} assert queue.getQueuedJobs() == 7 @@ -185,11 +185,11 @@ def test_pbsqueue_get_queued_jobs_default_zero(): def test_pbsqueue_get_other_jobs_sum_all_states(): queue = PBSQueue.__new__(PBSQueue) queue._job_numbers = { - "Transit": 1, - "Held": 2, # not counted as other - "Waiting": 3, # not counted as other - "Exiting": 4, - "Begun": 5, + "Transit": "1", + "Held": "2", # not counted as other + "Waiting": "3", # not counted as other + "Exiting": "4", + "Begun": "5", } assert queue.getOtherJobs() == 10 @@ -306,7 +306,7 @@ def test_pbsqueue_set_job_numbers_parses_valid_state_count(): queue._setJobNumbers() - assert queue._job_numbers == {"Running": 5, "Queued": 3, "Held": 1} + assert queue._job_numbers == {"Running": "5", "Queued": "3", "Held": "1"} def test_pbsqueue_set_job_numbers_handles_missing_state_count(): diff --git a/tests/test_cd_cli.py b/tests/test_cd_cli.py index a6a217e..9cf185c 100644 --- a/tests/test_cd_cli.py +++ b/tests/test_cd_cli.py @@ -15,7 +15,7 @@ @pytest.fixture(autouse=True) def register(): BatchMeta._registry.clear() - BatchMeta.register(PBS) + BatchMeta.registerBatchSystem(PBS) def _make_jobinfo_with_info(info: dict[str, str]) -> PBSJob: diff --git a/tests/test_clear.py b/tests/test_clear.py index 2f8f2d8..7824539 100644 --- a/tests/test_clear.py +++ b/tests/test_clear.py @@ -121,7 +121,7 @@ def test_clearer_clear_deletes_only_safe_files(tmp_path): messages = [call.args[0] for call in mock_info.call_args_list] assert any("Removed" in msg and "qq file" in msg for msg in messages) - assert any("not safe to clear" in msg for msg in messages) + assert any("could not be safely cleared" in msg for msg in messages) def test_clearer_clear_deletes_no_files_are_safe(tmp_path): diff --git a/tests/test_info_presenter.py b/tests/test_info_presenter.py index 85c9974..6e478d8 100644 --- a/tests/test_info_presenter.py +++ b/tests/test_info_presenter.py @@ -163,12 +163,12 @@ def test_create_job_status_panel(sample_info): assert len(panel_group.renderables) == 3 # panel - panel: Panel = panel_group.renderables[1] + panel = panel_group.renderables[1] assert isinstance(panel, Panel) - assert presenter._informer.info.job_id in panel.title.plain + assert presenter._informer.info.job_id in panel.title.plain # ty: ignore # table - table: Table = panel.renderable + table = panel.renderable assert isinstance(table, Table) assert len(table.columns) == 2 diff --git a/tests/test_properties_info.py b/tests/test_properties_info.py index aa8d217..b969c82 100644 --- a/tests/test_properties_info.py +++ b/tests/test_properties_info.py @@ -21,7 +21,7 @@ @pytest.fixture(autouse=True) def register(): - BatchMeta.register(PBS) + BatchMeta.registerBatchSystem(PBS) @pytest.fixture diff --git a/tests/test_run_runner.py b/tests/test_run_runner.py index e298f10..cb28eb6 100644 --- a/tests/test_run_runner.py +++ b/tests/test_run_runner.py @@ -210,7 +210,7 @@ def test_runner_handle_sigterm_performs_cleanup_and_exits(): runner._cleanup.assert_called_once() mock_logger.error.assert_called_once_with("Execution was terminated by SIGTERM.") mock_exit.assert_called_once_with(143) - assert exc_info.value.code == 143 # ty: ignore[unresolved-attribute] + assert exc_info.value.code == 143 def test_runner_cleanup_with_running_process(): diff --git a/tests/test_submit_cli.py b/tests/test_submit_cli.py index 346d2d7..9c6baf0 100644 --- a/tests/test_submit_cli.py +++ b/tests/test_submit_cli.py @@ -42,6 +42,8 @@ def test_submit_successful(tmp_path): assert any("job123" in msg for msg in info_messages) +# v0.7.0 - obsolete test - script validation is performed by click library itself +""" def test_submit_script_does_not_exist(tmp_path): runner = CliRunner() missing_script = tmp_path / "missing.sh" @@ -52,6 +54,7 @@ def test_submit_script_does_not_exist(tmp_path): assert result.exit_code == CFG.exit_codes.default error_messages = [call.args[0] for call in mock_logger.error.call_args_list] assert any("does not exist" in str(msg) for msg in error_messages) +""" def test_submit_detects_runtime_files_and_aborts(tmp_path): diff --git a/tests/test_submit_submitter.py b/tests/test_submit_submitter.py index 9ff6328..8539ea6 100644 --- a/tests/test_submit_submitter.py +++ b/tests/test_submit_submitter.py @@ -275,9 +275,12 @@ def test_submitter_create_env_vars_dict_sets_all_required_variables_with_per_nod assert env[CFG.env_vars.batch_system] == str(submitter._batch_system) assert env[CFG.env_vars.input_dir] == str(submitter._input_dir) assert env[CFG.env_vars.nnodes] == str(submitter._resources.nnodes) + assert submitter._resources.ncpus_per_node is not None + assert submitter._resources.nnodes is not None assert env[CFG.env_vars.ncpus] == str( submitter._resources.ncpus_per_node * submitter._resources.nnodes ) + assert submitter._resources.ngpus_per_node is not None assert env[CFG.env_vars.ngpus] == str( submitter._resources.ngpus_per_node * submitter._resources.nnodes ) diff --git a/uv.lock b/uv.lock index 9ac3802..85c1d86 100644 --- a/uv.lock +++ b/uv.lock @@ -603,7 +603,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.1a24" }, + { name = "ty", specifier = ">=0.0.18" }, ] [[package]] @@ -720,27 +720,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.1a24" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fc/71/a1db0d604be8d0067342e7aad74ab0c7fec6bea20eb33b6a6324baabf45f/ty-0.0.1a24.tar.gz", hash = "sha256:3273c514df5b9954c9928ee93b6a0872d12310ea8de42249a6c197720853e096", size = 4386721, upload-time = "2025-10-23T13:33:29.729Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/89/21fb275cb676d3480b67fbbf6eb162aec200b4dcb10c7885bffc754dc73f/ty-0.0.1a24-py3-none-linux_armv6l.whl", hash = "sha256:d478cd02278b988d5767df5821a0f03b99ef848f6fc29e8c77f30e859b89c779", size = 8833903, upload-time = "2025-10-23T13:32:53.552Z" }, - { url = "https://files.pythonhosted.org/packages/a2/22/beb127bce67fc2a1f3704b6b39505d77a7078a61becfbe10c5ee7ed9f5d8/ty-0.0.1a24-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:de758790f05f0a3bb396da4c75f770c85ab3a46095ec188b830c916bd5a5bc10", size = 8691210, upload-time = "2025-10-23T13:32:55.706Z" }, - { url = "https://files.pythonhosted.org/packages/39/bd/190f5e934339669191179fa01c60f5a140822dc465f0d4d312985903d109/ty-0.0.1a24-py3-none-macosx_11_0_arm64.whl", hash = "sha256:68f325ddc8cfb7a7883501e5e22f01284c5d5912aaa901d21e477f38edf4e625", size = 8138421, upload-time = "2025-10-23T13:32:58.718Z" }, - { url = "https://files.pythonhosted.org/packages/40/84/f08020dabad1e660957bb641b2ba42fe1e1e87192c234b1fc1fd6fb42cf2/ty-0.0.1a24-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49a52bbb1f8b0b29ad717d3fd70bd2afe752e991072fd13ff2fc14f03945c849", size = 8419861, upload-time = "2025-10-23T13:33:00.068Z" }, - { url = "https://files.pythonhosted.org/packages/e5/cc/e3812f7c1c2a0dcfb1bf8a5d6a7e5aa807a483a632c0d5734ea50a60a9ae/ty-0.0.1a24-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12945fe358fb0f73acf0b72a29efcc80da73f8d95cfe7f11a81e4d8d730e7b18", size = 8641443, upload-time = "2025-10-23T13:33:01.887Z" }, - { url = "https://files.pythonhosted.org/packages/e3/8b/3fc047d04afbba4780aba031dc80e06f6e95d888bbddb8fd6da502975cfb/ty-0.0.1a24-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6231e190989798b0860d15a8f225e3a06a6ce442a7083d743eb84f5b4b83b980", size = 8997853, upload-time = "2025-10-23T13:33:03.951Z" }, - { url = "https://files.pythonhosted.org/packages/e0/d9/ae1475d9200ecf6b196a59357ea3e4f4aa00e1d38c9237ca3f267a4a3ef7/ty-0.0.1a24-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7c6401f4a7532eab63dd7fe015c875792a701ca4b1a44fc0c490df32594e071f", size = 9676864, upload-time = "2025-10-23T13:33:05.744Z" }, - { url = "https://files.pythonhosted.org/packages/cc/d9/abd6849f0601b24d5d5098e47b00dfbdfe44a4f6776f2e54a21005739bdf/ty-0.0.1a24-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:83c69759bfa2a00278aa94210eded35aea599215d16460445cbbf5b36f77c454", size = 9351386, upload-time = "2025-10-23T13:33:07.807Z" }, - { url = "https://files.pythonhosted.org/packages/63/5c/639e0fe3b489c65b12b38385fe5032024756bc07f96cd994d7df3ab579ef/ty-0.0.1a24-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71146713cb8f804aad2b2e87a8efa7e7df0a5a25aed551af34498bcc2721ae03", size = 9517674, upload-time = "2025-10-23T13:33:09.641Z" }, - { url = "https://files.pythonhosted.org/packages/78/ae/323f373fcf54a883e39ea3fb6f83ed6d1eda6dfd8246462d0cfd81dac781/ty-0.0.1a24-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4836854411059de592f0ecc62193f2b24fc3acbfe6ce6ce0bf2c6d1a5ea9de7", size = 9000468, upload-time = "2025-10-23T13:33:11.51Z" }, - { url = "https://files.pythonhosted.org/packages/14/26/1a4be005aa4326264f0e7ce554844d5ef8afc4c5600b9a38b05671e9ed18/ty-0.0.1a24-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a7f0b8546d27605e09cd0fe08dc28c1d177bf7498316dd11c3bb8ef9440bf2e1", size = 8377164, upload-time = "2025-10-23T13:33:13.504Z" }, - { url = "https://files.pythonhosted.org/packages/73/2f/dcd6b449084e53a2beb536d8721a2517143a2353413b5b323d6eb9a31705/ty-0.0.1a24-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4e2fbf7dce2311127748824e03d9de2279e96ab5713029c3fa58acbaf19b2f51", size = 8672709, upload-time = "2025-10-23T13:33:15.213Z" }, - { url = "https://files.pythonhosted.org/packages/dc/2e/8b3b45d46085a79547e6db5295f42c6b798a0240d34454181e2ca947183c/ty-0.0.1a24-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f35b7f0a65f7e34e59f34173164946c89a4c4b1d1c18cabe662356a35f33efcd", size = 8788732, upload-time = "2025-10-23T13:33:17.347Z" }, - { url = "https://files.pythonhosted.org/packages/cf/c5/7675ff8693ad13044d86d8d4c824caf6bbb00340df05ad93d0e9d1e0338b/ty-0.0.1a24-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:120fe95eaf2a200f531f949e3dd0a9d95ab38915ce388412873eae28c499c0b9", size = 9095693, upload-time = "2025-10-23T13:33:19.836Z" }, - { url = "https://files.pythonhosted.org/packages/62/0b/bdba5d31aa3f0298900675fd355eec63a9c682aa46ef743dbac8f28b4608/ty-0.0.1a24-py3-none-win32.whl", hash = "sha256:d8d8379264a8c14e1f4ca9e117e72df3bf0a0b0ca64c5fd18affbb6142d8662a", size = 8361302, upload-time = "2025-10-23T13:33:21.572Z" }, - { url = "https://files.pythonhosted.org/packages/b4/48/127a45e16c49563df82829542ca64b0bc387591a777df450972bc85957e6/ty-0.0.1a24-py3-none-win_amd64.whl", hash = "sha256:2e826d75bddd958643128c309f6c47673ed6cef2ea5f2b3cd1a1159a1392971a", size = 9039221, upload-time = "2025-10-23T13:33:23.055Z" }, - { url = "https://files.pythonhosted.org/packages/31/67/9161fbb8c1a2005938bdb5ccd4e4c98ee4bea2d262afb777a4b69aa15eb5/ty-0.0.1a24-py3-none-win_arm64.whl", hash = "sha256:2efbfcdc94d306f0d25f3efe2a90c0f953132ca41a1a47d0bae679d11cdb15aa", size = 8514044, upload-time = "2025-10-23T13:33:27.816Z" }, +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" }, ] [[package]]