From abad236b0e4306ab3c1c7c38c568ca694480f826 Mon Sep 17 00:00:00 2001 From: Chris Holden Date: Wed, 11 Nov 2026 15:12:58 -0500 Subject: [PATCH 1/4] feat: workflow submit & terminate scripts --- src/hls-submit/README.md | 58 +++ src/hls-submit/pyproject.toml | 45 ++ src/hls-submit/src/hls_submit/__init__.py | 1 + src/hls-submit/src/hls_submit/cli.py | 32 ++ src/hls-submit/src/hls_submit/common.py | 444 ++++++++++++++++++ src/hls-submit/src/hls_submit/fmask.py | 34 ++ src/hls-submit/src/hls_submit/lasrc.py | 63 +++ src/hls-submit/src/hls_submit/py.typed | 0 src/hls-submit/src/hls_submit/resources.py | 83 ++++ src/hls-submit/src/hls_submit/terminate.py | 151 +++++++ src/hls-submit/tests/conftest.py | 111 +++++ src/hls-submit/tests/test_cli.py | 308 +++++++++++++ src/hls-submit/tests/test_common.py | 154 +++++++ src/hls-submit/tests/test_resources.py | 59 +++ src/hls-submit/uv.lock | 499 +++++++++++++++++++++ 15 files changed, 2042 insertions(+) create mode 100644 src/hls-submit/README.md create mode 100644 src/hls-submit/pyproject.toml create mode 100644 src/hls-submit/src/hls_submit/__init__.py create mode 100644 src/hls-submit/src/hls_submit/cli.py create mode 100644 src/hls-submit/src/hls_submit/common.py create mode 100644 src/hls-submit/src/hls_submit/fmask.py create mode 100644 src/hls-submit/src/hls_submit/lasrc.py create mode 100644 src/hls-submit/src/hls_submit/py.typed create mode 100644 src/hls-submit/src/hls_submit/resources.py create mode 100644 src/hls-submit/src/hls_submit/terminate.py create mode 100644 src/hls-submit/tests/conftest.py create mode 100644 src/hls-submit/tests/test_cli.py create mode 100644 src/hls-submit/tests/test_common.py create mode 100644 src/hls-submit/tests/test_resources.py create mode 100644 src/hls-submit/uv.lock diff --git a/src/hls-submit/README.md b/src/hls-submit/README.md new file mode 100644 index 00000000..8c978850 --- /dev/null +++ b/src/hls-submit/README.md @@ -0,0 +1,58 @@ +# hls-submit + +Operator tooling for running HLS science experiments (LaSRC, Fmask) on the AWS +Batch infrastructure deployed by `hls-orchestration`, e.g. for performance +testing or validating algorithm changes. It is not installed in the container +image. + +Job queues and job definitions are looked up from the orchestration +CloudFormation stack (`--stack-name` or `HLS_STACK_NAME`) by logical ID, so no +ARNs are hardcoded. Job definitions are always submitted without a revision, so +Batch runs the latest ACTIVE one. Pass `--job-queue` and `--job-definition` to +skip the lookup. + +## Usage + +``` +pixi run -e dev hls-submit --help +# or, outside of pixi +uv run --project src/hls-submit hls-submit --help +``` + +Every submit command is a dry run unless `--submit` is given: + +``` +export HLS_STACK_NAME=hls-mcp-development-viirs + +# A CSV with several columns: name the one holding L1 input granule IDs +hls-submit fmask Belmanip2_HLS_sample_Jan_L1.csv --granule-column L1_ID --sensor L30 + +# A plain list of L1 IDs, one per line, with or without a header +hls-submit lasrc Belmanip2_HLS_sample_S2.csv --sensor S30 --lasrc-impl rust --submit + +# Tag jobs for metrics (HLS_EXPERIMENT_FMASK_BUILD=baseline) and read +# Sentinel-2 inputs from a requester-pays bucket in another account +hls-submit fmask Belmanip2_HLS_sample_S2.csv --sensor S30 --rows 5 \ + --experiment fmask_build=baseline \ + --input-bucket hls-validation-datasets --input-prefix datasets/belmanip2/S30/ \ + --requester-pays --submit + +hls-submit terminate --sensor S30 +hls-submit terminate --sensor S30 --terminate +``` + +`--sensor` filters rows by the L1 ID (`LC08`/`LC09` for L30, `S2A`-`S2D` for +S30), so a CSV mixing both sensors works. `.SAFE` suffixes and `A+B` twin +granules are cleaned up. + +`--experiment KEY=VALUE` (repeatable) sets `HLS_EXPERIMENT_=VALUE`, which +the workflows record as a CloudWatch metrics dimension; the values are also +added to job names. `lasrc` additionally tags each job with +`HLS_EXPERIMENT_LASRC_IMPL=`. + +Metrics go to `/hls-orchestration//log-metrics` unless +`--metric-log-group` is given; with neither a stack nor a log group, jobs do +not record metrics. + +AWS credentials and profile come from the usual environment (`AWS_PROFILE`, +etc). diff --git a/src/hls-submit/pyproject.toml b/src/hls-submit/pyproject.toml new file mode 100644 index 00000000..61f3f23e --- /dev/null +++ b/src/hls-submit/pyproject.toml @@ -0,0 +1,45 @@ +[project] +name = "hls-submit" +version = "0.1.0" +description = "Submit and manage HLS science experiment jobs on AWS Batch" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "boto3>=1.42.34", + "click>=8.1", +] + +[project.scripts] +hls-submit = "hls_submit.cli:cli" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project.optional-dependencies] +dev = [ + "boto3-stubs[batch,cloudformation]>=1.36.0", + "mypy>=1.14.0", + "pytest>=9.0.2", + "ruff>=0.9.0", +] + +[dependency-groups] +dev = [ + "hls-submit[dev]", +] + +[tool.ruff] +line-length = 88 +target-version = "py312" + +[tool.ruff.lint] +select = ["F", "E", "W", "I", "UP"] +ignore = ["E501", "E731"] + +[tool.ruff.lint.isort] +known-first-party = ["hls_submit"] + +[tool.mypy] +python_version = "3.13" +strict = true diff --git a/src/hls-submit/src/hls_submit/__init__.py b/src/hls-submit/src/hls_submit/__init__.py new file mode 100644 index 00000000..cacea8f6 --- /dev/null +++ b/src/hls-submit/src/hls_submit/__init__.py @@ -0,0 +1 @@ +"""Submit and manage HLS science experiment jobs on AWS Batch.""" diff --git a/src/hls-submit/src/hls_submit/cli.py b/src/hls-submit/src/hls_submit/cli.py new file mode 100644 index 00000000..afe9d575 --- /dev/null +++ b/src/hls-submit/src/hls_submit/cli.py @@ -0,0 +1,32 @@ +"""``hls-submit`` command line entry point.""" + +from __future__ import annotations + +from typing import Any + +import click +from botocore.exceptions import BotoCoreError, ClientError + +from .fmask import fmask +from .lasrc import lasrc +from .terminate import terminate + + +class _AwsErrorGroup(click.Group): + """Report AWS errors (expired credentials, missing stack) without a traceback.""" + + def invoke(self, ctx: click.Context) -> Any: + try: + return super().invoke(ctx) + except (BotoCoreError, ClientError) as err: + raise click.ClickException(f"AWS error: {err}") from err + + +@click.group(cls=_AwsErrorGroup) +def cli() -> None: + """Submit and manage HLS science experiment jobs on AWS Batch.""" + + +cli.add_command(fmask) +cli.add_command(lasrc) +cli.add_command(terminate) diff --git a/src/hls-submit/src/hls_submit/common.py b/src/hls-submit/src/hls_submit/common.py new file mode 100644 index 00000000..0a94a9e7 --- /dev/null +++ b/src/hls-submit/src/hls_submit/common.py @@ -0,0 +1,444 @@ +"""Shared options and helpers for building and submitting AWS Batch jobs.""" + +from __future__ import annotations + +import csv +import functools +import re +from collections.abc import Callable, Sequence +from dataclasses import dataclass, fields +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import boto3 +import click +from botocore.config import Config + +from .resources import ( + ResourceLookupError, + find_job_definition, + find_job_queue, + require_unversioned, +) + +if TYPE_CHECKING: + from mypy_boto3_batch import BatchClient + from mypy_boto3_cloudformation import CloudFormationClient + +DEFAULT_REGION = "us-west-2" +DEFAULT_OUTPUT_BUCKET = "hls-science-container-testing" +EXPERIMENT_PREFIX = "HLS_EXPERIMENT_" + + +@dataclass(frozen=True) +class Sensor: + """Batch resources (by CDK construct name) and default inputs for a sensor.""" + + job_queue: str + job_definition: str + input_bucket: str + + +SENSORS: dict[str, Sensor] = { + "L30": Sensor( + job_queue="BatchLandsatAcJobQueue", + job_definition="LandsatTaskBatchJob", + input_bucket="usgs-landsat", + ), + "S30": Sensor( + job_queue="BatchSentinelJobQueue", + job_definition="SentinelTaskBatchJob", + input_bucket="hls-science-container-testing", + ), +} + + +def batch_client(region: str, max_pool_connections: int = 10) -> BatchClient: + """Batch client; size ``max_pool_connections`` to the number of threads using it.""" + config = Config( + max_pool_connections=max_pool_connections, + retries={"mode": "adaptive", "max_attempts": 10}, + ) + return boto3.client("batch", region_name=region, config=config) + + +def cloudformation_client(region: str) -> CloudFormationClient: + return boto3.client("cloudformation", region_name=region) + + +# ----- Granule input +_L1_ID_SENSORS = ( + (re.compile(r"^LC0[89]_"), "L30"), + (re.compile(r"^S2[A-D]_MSIL1C_"), "S30"), +) + + +def clean_l1_id(raw: str) -> str: + """Normalize an L1 ID cell to the GRANULE value the workflows expect. + + Twin Sentinel-2 granules are listed as "A+B"; only the first is used. + """ + return raw.split("+")[0].strip().removesuffix(".SAFE") + + +def l1_sensor(l1_id: str) -> str | None: + """Return the HLS product ("L30" or "S30") built from an L1 ID, if known.""" + for pattern, sensor in _L1_ID_SENSORS: + if pattern.match(l1_id): + return sensor + return None + + +def _l1_values(csv_file: Path, column: str | None) -> list[str]: + """Return the raw L1 ID cells of a CSV, in row order. + + A single-column file with no header (one L1 ID per line) is also accepted. + """ + with open(csv_file, newline="") as fh: + table = [row for row in csv.reader(fh) if any(c.strip() for c in row)] + if not table: + return [] + + header, *body = table + if column is None and len(header) == 1 and l1_sensor(clean_l1_id(header[0])): + return [row[0] for row in table] + + if column is None: + if len(header) != 1: + raise click.ClickException( + f"{csv_file} has columns {', '.join(header)}; pass --granule-column " + "to name the one holding L1 granule IDs." + ) + index = 0 + elif column in header: + index = header.index(column) + else: + raise click.ClickException( + f"{csv_file} has no column {column!r}; found {', '.join(header)}." + ) + return [row[index] for row in body] + + +def read_granules( + csv_file: Path, + sensor: str, + column: str | None = None, + row_offset: int = 0, + rows: int | None = None, +) -> list[str]: + """Read the L1 granule IDs for ``sensor`` from a CSV.""" + l1_ids = [clean_l1_id(v) for v in _l1_values(csv_file, column)][row_offset:] + + selected = [] + for l1_id in l1_ids: + found = l1_sensor(l1_id) + if found is None: + raise click.ClickException( + f"{l1_id!r} in {csv_file} is not a Landsat or Sentinel-2 L1 granule " + "ID. Is --granule-column the L1 input, not the HLS output?" + ) + if found == sensor: + selected.append(l1_id) + return selected if rows is None else selected[:rows] + + +def job_name(*parts: str | None) -> str: + """Join non-empty parts into a valid Batch job name ([A-Za-z0-9_-], <= 128).""" + stem = "-".join(p for p in parts if p) + return re.sub(r"[^A-Za-z0-9_-]", "-", stem)[:128] + + +def _parse_experiments( + ctx: click.Context, param: click.Parameter, values: tuple[str, ...] +) -> dict[str, str]: + experiments: dict[str, str] = {} + for value in values: + key, sep, val = value.partition("=") + if not sep or not re.fullmatch(r"[A-Za-z0-9_]+", key) or not val: + raise click.BadParameter(f"expected KEY=VALUE, got {value!r}") + experiments[key.upper()] = val + return experiments + + +# ----- Shared CLI options +stack_name_option = click.option( + "--stack-name", + envvar="HLS_STACK_NAME", + default=None, + help=( + "hls-orchestration CloudFormation stack to look up Batch resources in " + "(env: HLS_STACK_NAME)." + ), +) +region_option = click.option( + "--region", default=DEFAULT_REGION, show_default=True, help="AWS region." +) + + +@dataclass(frozen=True, kw_only=True) +class SubmitOptions: + """Options shared by every job-submitting command.""" + + csv_file: Path + granule_column: str | None + sensor: str + rows: int | None + row_offset: int + experiments: dict[str, str] + stack_name: str | None + region: str + job_queue: str | None + job_definition: str | None + input_bucket: str | None + input_prefix: str | None + requester_pays: bool + output_bucket: str + debug_bucket: str | None + metric_log_group: str | None + threads: int + submit: bool + + @property + def sensor_config(self) -> Sensor: + return SENSORS[self.sensor] + + @property + def metric_log_group_name(self) -> str | None: + if self.metric_log_group: + return self.metric_log_group + if self.stack_name: + return f"/hls-orchestration/{self.stack_name}/log-metrics" + return None + + @property + def experiment_labels(self) -> list[str]: + """Experiment values, in the order given, for use in job names.""" + return list(self.experiments.values()) + + def read_granules(self) -> list[str]: + return read_granules( + self.csv_file, self.sensor, self.granule_column, self.row_offset, self.rows + ) + + def base_environment(self, default_s30_input_prefix: str = "") -> dict[str, str]: + """Container environment common to every job of a submission.""" + env = { + "AWS_DEFAULT_REGION": self.region, + "INPUT_BUCKET": self.input_bucket or self.sensor_config.input_bucket, + "OUTPUT_BUCKET": self.output_bucket, + "DEBUG_BUCKET": self.debug_bucket or self.output_bucket, + # Required by the Sentinel environment config, unused by experiments. + "GIBS_OUTPUT_BUCKET": "foo", + "OMP_NUM_THREADS": str(self.threads), + } + if self.metric_log_group_name: + env["METRIC_LOG_GROUP_NAME"] = self.metric_log_group_name + input_prefix = self.input_prefix + if input_prefix is None and self.sensor == "S30": + input_prefix = default_s30_input_prefix + if input_prefix: + env["INPUT_PREFIX"] = input_prefix + if self.requester_pays: + env["INPUT_REQUESTER_PAYS"] = "true" + for key, value in self.experiments.items(): + env[f"{EXPERIMENT_PREFIX}{key}"] = value + return env + + def resolve_batch_targets(self) -> tuple[str, str]: + """Return the (job queue, job definition) to submit to.""" + try: + if self.job_definition is not None: + require_unversioned(self.job_definition) + if self.job_queue is not None and self.job_definition is not None: + return self.job_queue, self.job_definition + if self.stack_name is None: + raise click.UsageError( + "Pass --stack-name (or set HLS_STACK_NAME) to look up Batch " + "resources, or pass both --job-queue and --job-definition." + ) + + cfn = cloudformation_client(self.region) + queue = self.job_queue or find_job_queue( + cfn, self.stack_name, self.sensor_config.job_queue + ) + definition = self.job_definition or find_job_definition( + cfn, self.stack_name, self.sensor_config.job_definition + ) + except ResourceLookupError as err: + raise click.ClickException(str(err)) from err + return queue, definition + + +_SUBMIT_OPTION_NAMES = [f.name for f in fields(SubmitOptions)] + +_SUBMIT_OPTION_DECORATORS: list[Callable[[Callable[..., Any]], Callable[..., Any]]] = [ + click.argument( + "csv_file", type=click.Path(exists=True, dir_okay=False, path_type=Path) + ), + click.option( + "--granule-column", + default=None, + help=( + "CSV column holding the L1 input granule IDs. Optional for a " + "single-column file, with or without a header." + ), + ), + click.option( + "--sensor", + type=click.Choice(list(SENSORS)), + required=True, + help="Submit only this sensor's granules, by L1 ID (LC08/LC09 or S2A-D).", + ), + click.option( + "--rows", + type=int, + default=None, + help="Submit at most this many granules (after filtering). Default: all.", + ), + click.option( + "--row-offset", type=int, default=0, help="Skip this many CSV rows first." + ), + click.option( + "--experiment", + "experiments", + multiple=True, + callback=_parse_experiments, + metavar="KEY=VALUE", + help=( + f"Set {EXPERIMENT_PREFIX}=VALUE, a CloudWatch metrics dimension. " + "Values are also added to job names. Repeatable." + ), + ), + stack_name_option, + region_option, + click.option( + "--job-queue", + default=None, + help="Job queue name or ARN. Default: looked up from the stack.", + ), + click.option( + "--job-definition", + default=None, + help=( + "Job definition name or ARN, without a revision. " + "Default: looked up from the stack." + ), + ), + click.option( + "--input-bucket", + default=None, + help="Bucket holding the input granules. Default: per --sensor.", + ), + click.option( + "--input-prefix", + default=None, + help="Key prefix of the Sentinel-2 granule ZIPs in --input-bucket.", + ), + click.option( + "--requester-pays", + is_flag=True, + default=False, + help=( + "Read Sentinel-2 inputs from a requester-pays bucket owned by another " + "account (sets INPUT_REQUESTER_PAYS=true). Landsat always pays." + ), + ), + click.option( + "--output-bucket", + default=DEFAULT_OUTPUT_BUCKET, + show_default=True, + help="Bucket for job outputs.", + ), + click.option( + "--debug-bucket", + default=None, + help="Bucket for debug outputs. Default: --output-bucket.", + ), + click.option( + "--metric-log-group", + default=None, + help=( + "Metrics log group. Default: /hls-orchestration//log-metrics " + "with --stack-name, otherwise metrics are not recorded." + ), + ), + click.option( + "--threads", + type=click.IntRange(min=1), + default=2, + show_default=True, + help=( + "Worker threads per job. Batch allocates vCPUs via cpu shares, so an " + "unconstrained job sees every core on the instance." + ), + ), + click.option( + "--submit", + is_flag=True, + default=False, + help="Actually submit jobs. Without this flag the run is a dry run.", + ), +] + + +def submit_options(func: Callable[..., None]) -> Callable[..., None]: + """Add the shared submit options, passed to ``func`` as a ``SubmitOptions``.""" + + @functools.wraps(func) + def wrapper(**kwargs: Any) -> None: + options = SubmitOptions(**{k: kwargs.pop(k) for k in _SUBMIT_OPTION_NAMES}) + func(options, **kwargs) + + decorated: Callable[..., Any] = wrapper + for decorator in reversed(_SUBMIT_OPTION_DECORATORS): + decorated = decorator(decorated) + return decorated + + +# ----- Submission +@dataclass(frozen=True) +class JobSpec: + """A single job to submit.""" + + name: str + environment: dict[str, str] + granule: str + + +def submit_jobs(options: SubmitOptions, command: str, jobs: Sequence[JobSpec]) -> None: + """Submit ``jobs`` running ``command``, or print them on a dry run.""" + job_queue, job_definition = options.resolve_batch_targets() + + mode = "SUBMIT" if options.submit else "DRY-RUN" + experiments = ( + ", ".join(f"{k}={v}" for k, v in options.experiments.items()) or "none" + ) + metrics = options.metric_log_group_name or "not recorded" + click.echo( + f"[{mode}] sensor={options.sensor} {len(jobs)} job(s)\n" + f" queue={job_queue}\n" + f" job definition={job_definition}\n" + f" experiments={experiments}\n" + f" metrics={metrics}" + ) + + batch = batch_client(options.region) if options.submit else None + for job in jobs: + if batch is None: + click.echo(f" would submit {job.name} (GRANULE={job.granule})") + continue + resp = batch.submit_job( + jobName=job.name, + jobQueue=job_queue, + jobDefinition=job_definition, + containerOverrides={ + "command": [command], + "environment": [ + {"name": k, "value": v} for k, v in job.environment.items() + ], + }, + ) + click.echo(f" submitted {job.name} -> {resp['jobId']}") + + if batch is None: + click.echo("Re-run with --submit to actually submit these jobs.") diff --git a/src/hls-submit/src/hls_submit/fmask.py b/src/hls-submit/src/hls_submit/fmask.py new file mode 100644 index 00000000..04c7397e --- /dev/null +++ b/src/hls-submit/src/hls_submit/fmask.py @@ -0,0 +1,34 @@ +"""Submit standalone Fmask jobs.""" + +from __future__ import annotations + +import click + +from .common import JobSpec, SubmitOptions, job_name, submit_jobs, submit_options + +COMMAND = "hls-workflows fmask" +DEFAULT_S30_INPUT_PREFIX = "datasets/belmanip2/S30/" + + +def build_jobs(options: SubmitOptions) -> list[JobSpec]: + """One `hls-workflows fmask` job per granule.""" + base = options.base_environment(DEFAULT_S30_INPUT_PREFIX) + return [ + JobSpec( + name=job_name("fmask", *options.experiment_labels, l1_id), + environment={**base, "GRANULE": l1_id}, + granule=l1_id, + ) + for l1_id in options.read_granules() + ] + + +@click.command() +@submit_options +def fmask(options: SubmitOptions) -> None: + """Submit Fmask jobs for the --sensor granules in CSV_FILE. + + Outputs land in s3:///fmask///, where + the version is the fmask package baked into the job definition's image. + """ + submit_jobs(options, COMMAND, build_jobs(options)) diff --git a/src/hls-submit/src/hls_submit/lasrc.py b/src/hls-submit/src/hls_submit/lasrc.py new file mode 100644 index 00000000..639ea0bc --- /dev/null +++ b/src/hls-submit/src/hls_submit/lasrc.py @@ -0,0 +1,63 @@ +"""Submit LaSRC jobs, one per granule and implementation.""" + +from __future__ import annotations + +import click + +from .common import ( + EXPERIMENT_PREFIX, + JobSpec, + SubmitOptions, + job_name, + submit_jobs, + submit_options, +) + +COMMAND = "hls-workflows lasrc" +DEFAULT_S30_INPUT_PREFIX = "inputs/lasrc-rs/S30" +LASRC_IMPLS = ("c", "rust") + + +def build_jobs(options: SubmitOptions, impls: tuple[str, ...]) -> list[JobSpec]: + """One `hls-workflows lasrc` job per granule per implementation.""" + base = { + **options.base_environment(DEFAULT_S30_INPUT_PREFIX), + "VIIRS_AUX_STARTING_DATE": "20210101", + "LASRC_AUX_DIR": "/var/lasrc_aux", + # OMP_NUM_THREADS caps the C OpenMP loops; the Rust rayon pool reads + # RAYON_NUM_THREADS instead. + "RAYON_NUM_THREADS": str(options.threads), + } + return [ + JobSpec( + name=job_name("lasrc", *options.experiment_labels, impl, l1_id), + environment={ + **base, + "GRANULE": l1_id, + "LASRC_IMPL": impl, + f"{EXPERIMENT_PREFIX}LASRC_IMPL": impl, + }, + granule=l1_id, + ) + for l1_id in options.read_granules() + for impl in impls + ] + + +@click.command() +@submit_options +@click.option( + "--lasrc-impl", + "impls", + type=click.Choice(LASRC_IMPLS), + multiple=True, + default=LASRC_IMPLS, + show_default=True, + help=( + "LaSRC implementation(s) to submit per granule (repeatable). Each job is " + f"tagged {EXPERIMENT_PREFIX}LASRC_IMPL=." + ), +) +def lasrc(options: SubmitOptions, impls: tuple[str, ...]) -> None: + """Submit LaSRC jobs for the --sensor granules in CSV_FILE.""" + submit_jobs(options, COMMAND, build_jobs(options, impls)) diff --git a/src/hls-submit/src/hls_submit/py.typed b/src/hls-submit/src/hls_submit/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/hls-submit/src/hls_submit/resources.py b/src/hls-submit/src/hls_submit/resources.py new file mode 100644 index 00000000..280ddb4c --- /dev/null +++ b/src/hls-submit/src/hls_submit/resources.py @@ -0,0 +1,83 @@ +"""Look up AWS Batch resources deployed by an hls-orchestration stack.""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from mypy_boto3_cloudformation import CloudFormationClient + +JOB_QUEUE_TYPE = "AWS::Batch::JobQueue" +JOB_DEFINITION_TYPE = "AWS::Batch::JobDefinition" + +_REVISION = re.compile(r":\d+$") + + +class ResourceLookupError(Exception): + """A stack resource could not be resolved to exactly one match.""" + + +def _logical_id_pattern(name: str) -> re.Pattern[str]: + # CDK logical IDs are the construct path plus an 8 hex character hash, so + # anchoring on the hash keeps e.g. "BatchSentinelJobQueue" from matching + # "BatchSentinelHistoricJobQueue". + return re.compile(rf"^{re.escape(name)}[0-9A-F]{{8}}$") + + +def find_stack_resource( + cfn: CloudFormationClient, stack_name: str, resource_type: str, name: str +) -> str: + """Return the physical ID of the ``resource_type`` named ``name`` in a stack. + + ``name`` is the CDK construct name, i.e. the logical ID without its hash + suffix (e.g. "BatchSentinelJobQueue"). + """ + pattern = _logical_id_pattern(name) + available: list[str] = [] + matches: list[str] = [] + + paginator = cfn.get_paginator("list_stack_resources") + for page in paginator.paginate(StackName=stack_name): + for resource in page["StackResourceSummaries"]: + if resource["ResourceType"] != resource_type: + continue + logical_id = resource["LogicalResourceId"] + available.append(logical_id) + if pattern.match(logical_id) and resource.get("PhysicalResourceId"): + matches.append(resource["PhysicalResourceId"]) + + if len(matches) != 1: + raise ResourceLookupError( + f"Expected one {resource_type} named {name!r} in stack {stack_name!r}, " + f"found {len(matches)}. Available: {', '.join(sorted(available)) or 'none'}" + ) + return matches[0] + + +def strip_revision(job_definition: str) -> str: + """Drop a trailing ``:`` from a job definition name or ARN.""" + return _REVISION.sub("", job_definition) + + +def require_unversioned(job_definition: str) -> str: + """Reject job definitions pinned to a revision.""" + if _REVISION.search(job_definition): + raise ResourceLookupError( + f"Job definition {job_definition!r} is pinned to a revision. Pass it " + f"without one ({strip_revision(job_definition)!r}) so Batch uses the " + "latest ACTIVE revision." + ) + return job_definition + + +def find_job_queue(cfn: CloudFormationClient, stack_name: str, name: str) -> str: + """Return the ARN of a job queue in the stack.""" + return find_stack_resource(cfn, stack_name, JOB_QUEUE_TYPE, name) + + +def find_job_definition(cfn: CloudFormationClient, stack_name: str, name: str) -> str: + """Return the revision-less ARN of a job definition in the stack.""" + return strip_revision( + find_stack_resource(cfn, stack_name, JOB_DEFINITION_TYPE, name) + ) diff --git a/src/hls-submit/src/hls_submit/terminate.py b/src/hls-submit/src/hls_submit/terminate.py new file mode 100644 index 00000000..32d2f37b --- /dev/null +++ b/src/hls-submit/src/hls_submit/terminate.py @@ -0,0 +1,151 @@ +"""Terminate active jobs in a job queue.""" + +from __future__ import annotations + +from collections.abc import Sequence +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import TYPE_CHECKING + +import click +from botocore.exceptions import BotoCoreError, ClientError + +from .common import ( + SENSORS, + batch_client, + cloudformation_client, + region_option, + stack_name_option, +) +from .resources import ResourceLookupError, find_job_queue + +if TYPE_CHECKING: + from mypy_boto3_batch import BatchClient + from mypy_boto3_batch.literals import JobStatusType + +# Job states that can still be terminated; SUCCEEDED and FAILED are done. +ACTIVE_STATES: tuple[JobStatusType, ...] = ( + "SUBMITTED", + "PENDING", + "RUNNABLE", + "STARTING", + "RUNNING", +) + + +def list_active_jobs(batch: BatchClient, job_queue: str) -> list[tuple[str, str, str]]: + """Return (job ID, job name, state) for every active job in the queue.""" + jobs: list[tuple[str, str, str]] = [] + paginator = batch.get_paginator("list_jobs") + for state in ACTIVE_STATES: + for page in paginator.paginate(jobQueue=job_queue, jobStatus=state): + for job in page["jobSummaryList"]: + jobs.append((job["jobId"], job.get("jobName", ""), state)) + return jobs + + +def terminate_jobs( + batch: BatchClient, + jobs: Sequence[tuple[str, str, str]], + reason: str, + workers: int, +) -> list[tuple[str, str, str]]: + """Terminate ``jobs`` concurrently; return (job ID, job name, error) failures.""" + + def _terminate(job_id: str) -> None: + batch.terminate_job(jobId=job_id, reason=reason) + + failures: list[tuple[str, str, str]] = [] + with ThreadPoolExecutor(max_workers=workers) as pool: + futures = { + pool.submit(_terminate, job_id): (job_id, name) for job_id, name, _ in jobs + } + for done, future in enumerate(as_completed(futures), start=1): + job_id, name = futures[future] + try: + future.result() + except (BotoCoreError, ClientError) as err: + failures.append((job_id, name, str(err))) + click.echo(f" [{done}/{len(jobs)}] FAILED {job_id} {name}: {err}") + else: + click.echo(f" [{done}/{len(jobs)}] terminated {job_id} {name}") + return failures + + +@click.command() +@click.option( + "--sensor", + type=click.Choice(list(SENSORS)), + default=None, + help="Look up this sensor's job queue from the stack.", +) +@click.option( + "--job-queue", default=None, help="Job queue name or ARN. Overrides --sensor." +) +@stack_name_option +@region_option +@click.option( + "--reason", + default="Terminated by hls-submit", + show_default=True, + help="Reason recorded on each terminated job.", +) +@click.option( + "--workers", + type=click.IntRange(min=1), + default=16, + show_default=True, + help="Number of concurrent terminate_job calls.", +) +@click.option( + "--terminate", + is_flag=True, + default=False, + help="Actually terminate the jobs. Without this flag the run is a dry run.", +) +def terminate( + sensor: str | None, + job_queue: str | None, + stack_name: str | None, + region: str, + reason: str, + workers: int, + terminate: bool, +) -> None: + """Terminate all active jobs in a job queue. + + terminate_job cancels jobs that have not started and stops running ones, so + one call covers every active state. + """ + if job_queue is None: + if sensor is None or stack_name is None: + raise click.UsageError( + "Pass --job-queue, or --sensor with --stack-name (or HLS_STACK_NAME)." + ) + try: + job_queue = find_job_queue( + cloudformation_client(region), stack_name, SENSORS[sensor].job_queue + ) + except ResourceLookupError as err: + raise click.ClickException(str(err)) from err + + batch = batch_client(region, max_pool_connections=workers) + jobs = list_active_jobs(batch, job_queue) + if not jobs: + click.echo(f"No active jobs found in queue {job_queue}.") + return + + click.echo(f"Found {len(jobs)} active job(s) in queue {job_queue}:") + for job_id, name, state in jobs: + click.echo(f" [{state:9}] {job_id} {name}") + + if not terminate: + click.echo("Dry run. Re-run with --terminate to terminate these jobs.") + return + + failures = terminate_jobs(batch, jobs, reason, workers) + click.echo(f"Terminated {len(jobs) - len(failures)} of {len(jobs)} job(s).") + if failures: + raise click.ClickException( + f"{len(failures)} job(s) failed to terminate: " + + ", ".join(job_id for job_id, _, _ in failures) + ) diff --git a/src/hls-submit/tests/conftest.py b/src/hls-submit/tests/conftest.py new file mode 100644 index 00000000..e4a46a47 --- /dev/null +++ b/src/hls-submit/tests/conftest.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from collections.abc import Generator +from pathlib import Path +from typing import Any + +import boto3 +import pytest +from botocore.stub import Stubber + +STACK_NAME = "hls-test-stack" +ACCOUNT_ARN = "arn:aws:batch:us-west-2:123456789012" +SENTINEL_QUEUE_ARN = f"{ACCOUNT_ARN}:job-queue/BatchSentinelJobQueue2E4-abc" +SENTINEL_HISTORIC_QUEUE_ARN = f"{ACCOUNT_ARN}:job-queue/BatchSentinelHistoricJob-def" +LANDSAT_QUEUE_ARN = f"{ACCOUNT_ARN}:job-queue/BatchLandsatAcJobQueueD5-ghi" +SENTINEL_JOB_DEF_ARN = f"{ACCOUNT_ARN}:job-definition/SentinelTaskBatchJobDE3-92a4" +LANDSAT_JOB_DEF_ARN = f"{ACCOUNT_ARN}:job-definition/LandsatTaskBatchJob1274-d156" + + +def _resource(resource_type: str, logical_id: str, physical_id: str) -> dict[str, Any]: + return { + "ResourceType": resource_type, + "LogicalResourceId": logical_id, + "PhysicalResourceId": physical_id, + "LastUpdatedTimestamp": "2026-01-01T00:00:00Z", + "ResourceStatus": "UPDATE_COMPLETE", + } + + +STACK_RESOURCES = [ + _resource( + "AWS::Batch::ComputeEnvironment", + "BatchComputeEnvironment97329F07", + f"{ACCOUNT_ARN}:compute-environment/BatchComputeEnvironment9-xyz", + ), + _resource( + "AWS::Batch::JobQueue", "BatchSentinelJobQueue2E4B750B", SENTINEL_QUEUE_ARN + ), + _resource( + "AWS::Batch::JobQueue", + "BatchSentinelHistoricJobQueueB5C8120D", + SENTINEL_HISTORIC_QUEUE_ARN, + ), + _resource( + "AWS::Batch::JobQueue", "BatchLandsatAcJobQueueD5A33C58", LANDSAT_QUEUE_ARN + ), + _resource( + "AWS::Batch::JobDefinition", + "SentinelTaskBatchJobDE3AA4E9", + f"{SENTINEL_JOB_DEF_ARN}:54", + ), + _resource( + "AWS::Batch::JobDefinition", + "LandsatTaskBatchJob12745505", + f"{LANDSAT_JOB_DEF_ARN}:47", + ), +] + + +@pytest.fixture +def stack() -> dict[str, str]: + """Names and ARNs of the stubbed orchestration stack.""" + return { + "name": STACK_NAME, + "sentinel_queue": SENTINEL_QUEUE_ARN, + "sentinel_job_definition": SENTINEL_JOB_DEF_ARN, + } + + +@pytest.fixture +def stack_resources_response() -> dict[str, Any]: + return {"StackResourceSummaries": STACK_RESOURCES} + + +@pytest.fixture +def aws(monkeypatch: pytest.MonkeyPatch) -> Generator[dict[str, Stubber]]: + """Stubbed batch and cloudformation clients returned by ``boto3.client``.""" + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing") + monkeypatch.delenv("AWS_PROFILE", raising=False) + monkeypatch.delenv("HLS_STACK_NAME", raising=False) + + clients = { + service: boto3.client(service, region_name="us-west-2") + for service in ("batch", "cloudformation") + } + stubbers = {service: Stubber(client) for service, client in clients.items()} + for stubber in stubbers.values(): + stubber.activate() + + monkeypatch.setattr(boto3, "client", lambda service, **_: clients[service]) + yield stubbers + + for stubber in stubbers.values(): + stubber.assert_no_pending_responses() + stubber.deactivate() + + +@pytest.fixture +def granules_csv(tmp_path: Path) -> Path: + path = tmp_path / "granules.csv" + path.write_text( + "Tile_ID,Granule_ID,L1_ID\n" + "10SFJ,HLS.L30.T10SFJ.2025011T184522.v2.0,LC08_L1TP_044032_20250111_20250111_02_RT\n" + "32TQM,HLS.S30.T32TQM.2020001T102431.v2.0," + "S2A_MSIL1C_20200101T102431_N0208_R065_T32TQM_20200101T122841.SAFE\n" + "33UUP,HLS.S30.T33UUP.2020002T102431.v2.0," + "S2B_MSIL1C_20200102T102431_N0208_R065_T33UUP_20200102T122841" + "+S2B_MSIL1C_20200102T102431_N0208_R065_T33UUP_20200102T130000\n" + ) + return path diff --git a/src/hls-submit/tests/test_cli.py b/src/hls-submit/tests/test_cli.py new file mode 100644 index 00000000..34a83f0a --- /dev/null +++ b/src/hls-submit/tests/test_cli.py @@ -0,0 +1,308 @@ +from __future__ import annotations + +import threading +from pathlib import Path +from typing import Any + +from botocore.exceptions import ClientError +from botocore.stub import ANY, Stubber +from click.testing import CliRunner + +from hls_submit.cli import cli +from hls_submit.terminate import terminate_jobs + +CSV_ARGS = ["--granule-column", "L1_ID"] + + +def _stub_stack_lookups( + aws: dict[str, Stubber], stack_name: str, response: dict[str, Any], count: int = 2 +) -> None: + for _ in range(count): + aws["cloudformation"].add_response( + "list_stack_resources", response, {"StackName": stack_name} + ) + + +def test_fmask_dry_run_looks_up_stack( + aws: dict[str, Stubber], + stack: dict[str, str], + stack_resources_response: dict[str, Any], + granules_csv: Path, +) -> None: + _stub_stack_lookups(aws, stack["name"], stack_resources_response) + + result = CliRunner().invoke( + cli, + [ + "fmask", + str(granules_csv), + *CSV_ARGS, + "--sensor", + "S30", + "--stack-name", + stack["name"], + ], + ) + + assert result.exit_code == 0, result.output + assert "[DRY-RUN] sensor=S30 2 job(s)" in result.output + assert f"queue={stack['sentinel_queue']}\n" in result.output + assert f"job definition={stack['sentinel_job_definition']}\n" in result.output + assert "would submit fmask-S2A_MSIL1C" in result.output + + +def test_fmask_submit_requester_pays( + aws: dict[str, Stubber], + stack: dict[str, str], + stack_resources_response: dict[str, Any], + granules_csv: Path, +) -> None: + _stub_stack_lookups(aws, stack["name"], stack_resources_response) + granule = "S2A_MSIL1C_20200101T102431_N0208_R065_T32TQM_20200101T122841" + expected_env = { + "AWS_DEFAULT_REGION": "us-west-2", + "INPUT_BUCKET": "hls-validation-datasets", + "OUTPUT_BUCKET": "hls-science-container-testing", + "DEBUG_BUCKET": "hls-science-container-testing", + "GIBS_OUTPUT_BUCKET": "foo", + "OMP_NUM_THREADS": "2", + "METRIC_LOG_GROUP_NAME": f"/hls-orchestration/{stack['name']}/log-metrics", + "INPUT_PREFIX": "datasets/belmanip2/S30/", + "INPUT_REQUESTER_PAYS": "true", + "HLS_EXPERIMENT_FMASK_BUILD": "baseline", + "GRANULE": granule, + } + aws["batch"].add_response( + "submit_job", + {"jobName": "x", "jobId": "job-1"}, + { + "jobName": f"fmask-baseline-{granule}", + "jobQueue": stack["sentinel_queue"], + "jobDefinition": stack["sentinel_job_definition"], + "containerOverrides": { + "command": ["hls-workflows fmask"], + "environment": [ + {"name": k, "value": v} for k, v in expected_env.items() + ], + }, + }, + ) + + result = CliRunner().invoke( + cli, + [ + "fmask", + str(granules_csv), + *CSV_ARGS, + "--sensor", + "S30", + "--stack-name", + stack["name"], + "--rows", + "1", + "--input-bucket", + "hls-validation-datasets", + "--requester-pays", + "--experiment", + "fmask_build=baseline", + "--submit", + ], + ) + + assert result.exit_code == 0, result.output + assert "-> job-1" in result.output + + +def test_lasrc_overrides_skip_lookup( + aws: dict[str, Stubber], granules_csv: Path +) -> None: + for impl in ("c", "rust"): + aws["batch"].add_response( + "submit_job", + {"jobName": "x", "jobId": f"job-{impl}"}, + { + "jobName": f"lasrc-{impl}-LC08_L1TP_044032_20250111_20250111_02_RT", + "jobQueue": "my-queue", + "jobDefinition": "my-def", + "containerOverrides": { + "command": ["hls-workflows lasrc"], + "environment": ANY, + }, + }, + ) + + result = CliRunner().invoke( + cli, + [ + "lasrc", + str(granules_csv), + *CSV_ARGS, + "--sensor", + "L30", + "--job-queue", + "my-queue", + "--job-definition", + "my-def", + "--submit", + ], + ) + + assert result.exit_code == 0, result.output + assert "-> job-c" in result.output + assert "-> job-rust" in result.output + + +def test_job_definition_revision_rejected( + aws: dict[str, Stubber], granules_csv: Path +) -> None: + result = CliRunner().invoke( + cli, + [ + "fmask", + str(granules_csv), + *CSV_ARGS, + "--sensor", + "S30", + "--job-queue", + "my-queue", + "--job-definition", + "my-def:3", + ], + ) + + assert result.exit_code != 0 + assert "pinned to a revision" in result.output + + +def test_terminate_dry_run( + aws: dict[str, Stubber], + stack: dict[str, str], + stack_resources_response: dict[str, Any], +) -> None: + _stub_stack_lookups(aws, stack["name"], stack_resources_response, count=1) + for state in ("SUBMITTED", "PENDING", "RUNNABLE", "STARTING", "RUNNING"): + summaries = ( + [{"jobId": "job-1", "jobName": "fmask-a", "jobArn": "arn"}] + if state == "RUNNING" + else [] + ) + aws["batch"].add_response( + "list_jobs", + {"jobSummaryList": summaries}, + {"jobQueue": stack["sentinel_queue"], "jobStatus": state}, + ) + + result = CliRunner().invoke( + cli, ["terminate", "--sensor", "S30", "--stack-name", stack["name"]] + ) + + assert result.exit_code == 0, result.output + assert "[RUNNING ] job-1 fmask-a" in result.output + assert "Dry run." in result.output + + +def test_terminate_requires_target(aws: dict[str, Stubber]) -> None: + result = CliRunner().invoke(cli, ["terminate"]) + + assert result.exit_code != 0 + assert "Pass --job-queue, or --sensor with --stack-name" in result.output + + +def test_lookup_requires_stack_name( + aws: dict[str, Stubber], granules_csv: Path +) -> None: + result = CliRunner().invoke( + cli, ["fmask", str(granules_csv), *CSV_ARGS, "--sensor", "S30"] + ) + + assert result.exit_code != 0 + assert "Pass --stack-name (or set HLS_STACK_NAME)" in result.output + + +def test_overrides_without_stack_skip_metrics( + aws: dict[str, Stubber], granules_csv: Path +) -> None: + result = CliRunner().invoke( + cli, + [ + "fmask", + str(granules_csv), + *CSV_ARGS, + "--sensor", + "S30", + "--job-queue", + "my-queue", + "--job-definition", + "my-def", + ], + ) + + assert result.exit_code == 0, result.output + assert "metrics=not recorded" in result.output + + +def test_bad_experiment_rejected(aws: dict[str, Stubber], granules_csv: Path) -> None: + result = CliRunner().invoke( + cli, + ["fmask", str(granules_csv), "--sensor", "S30", "--experiment", "nope"], + ) + + assert result.exit_code != 0 + assert "expected KEY=VALUE" in result.output + + +def test_aws_error_reported_without_traceback( + aws: dict[str, Stubber], stack: dict[str, str], granules_csv: Path +) -> None: + aws["cloudformation"].add_client_error( + "list_stack_resources", + service_error_code="ExpiredToken", + service_message="The security token included in the request is expired", + ) + + result = CliRunner().invoke( + cli, + [ + "fmask", + str(granules_csv), + *CSV_ARGS, + "--sensor", + "S30", + "--stack-name", + stack["name"], + ], + ) + + assert result.exit_code == 1 + assert "AWS error:" in result.output + assert "ExpiredToken" in result.output + + +class _FakeBatch: + """Thread-safe stand-in for terminate_job; fails for job IDs in ``fail``.""" + + def __init__(self, fail: set[str]) -> None: + self.fail = fail + self.calls: list[tuple[str, str]] = [] + self._lock = threading.Lock() + + def terminate_job(self, *, jobId: str, reason: str) -> dict[str, Any]: + with self._lock: + self.calls.append((jobId, reason)) + if jobId in self.fail: + raise ClientError( + {"Error": {"Code": "ClientException", "Message": "nope"}}, + "TerminateJob", + ) + return {} + + +def test_terminate_jobs_concurrently_collects_failures() -> None: + jobs = [(f"job-{i}", f"name-{i}", "RUNNING") for i in range(20)] + batch = _FakeBatch(fail={"job-3", "job-11"}) + + failures = terminate_jobs(batch, jobs, "why", workers=4) # type: ignore[arg-type] + + assert sorted(job_id for job_id, _ in batch.calls) == sorted(j[0] for j in jobs) + assert all(reason == "why" for _, reason in batch.calls) + assert sorted(f[0] for f in failures) == ["job-11", "job-3"] diff --git a/src/hls-submit/tests/test_common.py b/src/hls-submit/tests/test_common.py new file mode 100644 index 00000000..e2a90366 --- /dev/null +++ b/src/hls-submit/tests/test_common.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import click +import pytest + +from hls_submit import lasrc +from hls_submit.common import SubmitOptions, job_name, l1_sensor, read_granules + +S2_A = "S2A_MSIL1C_20200101T102431_N0208_R065_T32TQM_20200101T122841" +S2_B = "S2B_MSIL1C_20200102T102431_N0208_R065_T33UUP_20200102T122841" +LC08 = "LC08_L1TP_044032_20250111_20250111_02_RT" + + +def _options(csv_file: Path, **overrides: Any) -> SubmitOptions: + values: dict[str, Any] = { + "csv_file": csv_file, + "granule_column": "L1_ID", + "sensor": "S30", + "rows": None, + "row_offset": 0, + "experiments": {}, + "stack_name": "my-stack", + "region": "us-west-2", + "job_queue": None, + "job_definition": None, + "input_bucket": None, + "input_prefix": None, + "requester_pays": False, + "output_bucket": "out", + "debug_bucket": None, + "metric_log_group": None, + "threads": 2, + "submit": False, + } + values.update(overrides) + return SubmitOptions(**values) + + +@pytest.mark.parametrize( + ("l1_id", "expected"), + [(LC08, "L30"), ("LC09_L1TP_x", "L30"), (S2_A, "S30"), ("S2C_MSIL1C_x", "S30")], +) +def test_l1_sensor(l1_id: str, expected: str) -> None: + assert l1_sensor(l1_id) == expected + + +def test_l1_sensor_rejects_hls_output_id() -> None: + assert l1_sensor("HLS.S30.T32TQM.2020001T102431.v2.0") is None + + +def test_read_granules_named_column(granules_csv: Path) -> None: + assert read_granules(granules_csv, "S30", column="L1_ID") == [S2_A, S2_B] + assert read_granules(granules_csv, "L30", column="L1_ID") == [LC08] + + +@pytest.mark.parametrize("header", ["", "my_granules\n"]) +def test_read_granules_single_column(tmp_path: Path, header: str) -> None: + path = tmp_path / "list.csv" + path.write_text(f"{header}{S2_A}.SAFE\n{LC08}\n\n{S2_B}\n") + + assert read_granules(path, "S30") == [S2_A, S2_B] + + +def test_read_granules_multi_column_needs_name(granules_csv: Path) -> None: + with pytest.raises(click.ClickException, match="pass --granule-column"): + read_granules(granules_csv, "S30") + + +def test_read_granules_unknown_column(granules_csv: Path) -> None: + with pytest.raises(click.ClickException, match="no column 'nope'"): + read_granules(granules_csv, "S30", column="nope") + + +def test_read_granules_rejects_output_ids(granules_csv: Path) -> None: + with pytest.raises(click.ClickException, match="not a Landsat or Sentinel-2"): + read_granules(granules_csv, "S30", column="Granule_ID") + + +@pytest.mark.parametrize( + ("row_offset", "rows", "expected"), + [(0, 1, [S2_A]), (2, None, [S2_B]), (0, None, [S2_A, S2_B])], +) +def test_read_granules_offset_and_rows( + granules_csv: Path, row_offset: int, rows: int | None, expected: list[str] +) -> None: + granules = read_granules( + granules_csv, "S30", column="L1_ID", row_offset=row_offset, rows=rows + ) + + assert granules == expected + + +def test_job_name_sanitizes_and_truncates() -> None: + assert job_name("fmask", None, "my.label", "S2A") == "fmask-my-label-S2A" + assert len(job_name("x" * 200)) == 128 + + +def test_base_environment_sentinel_defaults(granules_csv: Path) -> None: + env = _options(granules_csv).base_environment("default/prefix") + + assert env["INPUT_BUCKET"] == "hls-science-container-testing" + assert env["INPUT_PREFIX"] == "default/prefix" + assert env["DEBUG_BUCKET"] == "out" + assert env["METRIC_LOG_GROUP_NAME"] == "/hls-orchestration/my-stack/log-metrics" + assert "INPUT_REQUESTER_PAYS" not in env + assert not any(k.startswith("HLS_EXPERIMENT_") for k in env) + + +def test_base_environment_overrides(granules_csv: Path) -> None: + options = _options( + granules_csv, + input_bucket="hls-validation-datasets", + input_prefix="datasets/x/S30/", + requester_pays=True, + debug_bucket="dbg", + metric_log_group="/custom", + threads=4, + experiments={"FMASK_BUILD": "baseline", "RUN": "2"}, + ) + env = options.base_environment("default/prefix") + + assert env["INPUT_BUCKET"] == "hls-validation-datasets" + assert env["INPUT_PREFIX"] == "datasets/x/S30/" + assert env["INPUT_REQUESTER_PAYS"] == "true" + assert env["DEBUG_BUCKET"] == "dbg" + assert env["METRIC_LOG_GROUP_NAME"] == "/custom" + assert env["OMP_NUM_THREADS"] == "4" + assert env["HLS_EXPERIMENT_FMASK_BUILD"] == "baseline" + assert env["HLS_EXPERIMENT_RUN"] == "2" + + +def test_base_environment_no_stack_skips_metrics(granules_csv: Path) -> None: + env = _options(granules_csv, stack_name=None).base_environment() + + assert "METRIC_LOG_GROUP_NAME" not in env + + +def test_base_environment_landsat_has_no_prefix(granules_csv: Path) -> None: + env = _options(granules_csv, sensor="L30").base_environment("default/prefix") + + assert env["INPUT_BUCKET"] == "usgs-landsat" + assert "INPUT_PREFIX" not in env + + +def test_lasrc_jobs_tag_impl(granules_csv: Path) -> None: + options = _options(granules_csv, sensor="L30", experiments={"RUN": "r1"}) + jobs = lasrc.build_jobs(options, ("c", "rust")) + + assert [j.name for j in jobs] == [f"lasrc-r1-c-{LC08}", f"lasrc-r1-rust-{LC08}"] + assert [j.environment["HLS_EXPERIMENT_LASRC_IMPL"] for j in jobs] == ["c", "rust"] + assert all(j.environment["HLS_EXPERIMENT_RUN"] == "r1" for j in jobs) diff --git a/src/hls-submit/tests/test_resources.py b/src/hls-submit/tests/test_resources.py new file mode 100644 index 00000000..f3e15a2f --- /dev/null +++ b/src/hls-submit/tests/test_resources.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import Any + +import boto3 +import pytest +from botocore.stub import Stubber + +from hls_submit.resources import ( + ResourceLookupError, + find_job_definition, + find_job_queue, + require_unversioned, + strip_revision, +) + +JOB_DEF_ARN = "arn:aws:batch:us-west-2:123456789012:job-definition/SentinelTask-92a4" + + +@pytest.fixture +def cfn( + aws: dict[str, Stubber], + stack: dict[str, str], + stack_resources_response: dict[str, Any], +) -> Any: + aws["cloudformation"].add_response( + "list_stack_resources", stack_resources_response, {"StackName": stack["name"]} + ) + return boto3.client("cloudformation") + + +def test_find_job_queue_ignores_similar_names(cfn: Any, stack: dict[str, str]) -> None: + """BatchSentinelJobQueue must not also match BatchSentinelHistoricJobQueue.""" + queue = find_job_queue(cfn, stack["name"], "BatchSentinelJobQueue") + + assert queue == stack["sentinel_queue"] + + +def test_find_job_definition_strips_revision(cfn: Any, stack: dict[str, str]) -> None: + definition = find_job_definition(cfn, stack["name"], "SentinelTaskBatchJob") + + assert definition == stack["sentinel_job_definition"] + + +def test_find_job_queue_missing(cfn: Any, stack: dict[str, str]) -> None: + with pytest.raises(ResourceLookupError, match="found 0. Available: Batch"): + find_job_queue(cfn, stack["name"], "BatchLaadsJobQueue") + + +@pytest.mark.parametrize("job_definition", [f"{JOB_DEF_ARN}:54", "SentinelTask:3"]) +def test_require_unversioned_rejects_revision(job_definition: str) -> None: + with pytest.raises(ResourceLookupError, match="pinned to a revision"): + require_unversioned(job_definition) + + +@pytest.mark.parametrize("job_definition", [JOB_DEF_ARN, "SentinelTask"]) +def test_require_unversioned_accepts(job_definition: str) -> None: + assert require_unversioned(job_definition) == job_definition + assert strip_revision(job_definition) == job_definition diff --git a/src/hls-submit/uv.lock b/src/hls-submit/uv.lock new file mode 100644 index 00000000..db71b48e --- /dev/null +++ b/src/hls-submit/uv.lock @@ -0,0 +1,499 @@ +version = 1 +revision = 5 +requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "ast-serialize" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/d5/5dc1ee8a80c3b20a0b63c61b43ba6bc53517faecd1def47bcd752d22268e/ast_serialize-0.13.1.tar.gz", hash = "sha256:401cc84e8888d4d76f2c8d296fa006ed1a5ff993b6aff90dd777c03973efd025", size = 955137, upload-time = "2026-10-17T00:04:17.183Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/a8/fbd136b9f70c59e75f59a8e974c84102976f1201828b4f886480ba956936/ast_serialize-0.13.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3e129d44b0cffbea24a7f40117ce49e30993c3d119e584d4817ebd6f87246456", size = 897306, upload-time = "2026-10-17T00:02:52.479Z" }, + { url = "https://files.pythonhosted.org/packages/50/3f/2ad51e9823a790e7a70fd781e9595ab3abeace27f3aefcaa1939457efd72/ast_serialize-0.13.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:523d45bab16ddaf1399c04c4fe81bbf180f81e33c2f7bcbe76be56b0f7311ab4", size = 1236082, upload-time = "2026-10-17T00:02:54.82Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5c/8e959db5cc2237f9d4c560a8e2253c98f9746883fb4774651e4cda458901/ast_serialize-0.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:638333fb8ae72c62753f9e794fda20290883ddc65b0d85e26e08f8bb227567ad", size = 1221242, upload-time = "2026-10-17T00:02:56.395Z" }, + { url = "https://files.pythonhosted.org/packages/d6/05/ab1c56c3b7adcac88764bba609502d6b4989e449b2dd60eb478e1d20e369/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc34c670747d85d4232834665e247009413eaebe47c69eaf60a5c439aaecaed9", size = 1282105, upload-time = "2026-10-17T00:02:58.275Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f4/41981fcd2ddffe0760e5326be9dcbd2eda9478a8430a637e5123bd8fcb26/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8c8ebd419811c435812d242fb2a173e57dfede198c9ac9a0a584666ebc9b9331", size = 1287905, upload-time = "2026-10-17T00:03:00.068Z" }, + { url = "https://files.pythonhosted.org/packages/6a/43/b783addc5681153707121991b7b47892f7e9c8ba58a0d03b71a3b023ba47/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8899c038b1c15ae8b98e379057fb3ef398ceb0b21d1737a8156dd5a0923b7e51", size = 1541806, upload-time = "2026-10-17T00:03:01.503Z" }, + { url = "https://files.pythonhosted.org/packages/47/10/969db095038ee15270f65ad20d1129c5e4da605bf7e93b70ae640f1c8a9f/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e14c4de98f965647a74e6eaf37e20207124e45451ccd36e84a6f6ebab45e0372", size = 1309946, upload-time = "2026-10-17T00:03:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/06/dd/7c623d2fb0b868948952d1787e629a43b866f7d4caefd5445e20aefacec4/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f3e8a3eff69af640db985360fef35073fb46956dfc3e5c614cf00920d2746dd", size = 1302773, upload-time = "2026-10-17T00:03:04.378Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c2/4d49469110a19a44fc6269f4fd59679bcf84df068f185f89b73bee4da19b/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:75d7750fe98f6f35a96c43aba8d98a2e623e22c778a0c17ab9ede92eb10f5d82", size = 1299579, upload-time = "2026-10-17T00:03:05.88Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/2d381f1f262dda15affc8ad643fba1619eb07e7d9f8bd904deffef183006/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43e8ddd63e80fc280c2f790157386f27f9b9f6ccb9c4f8e803b863cbfb085769", size = 1355969, upload-time = "2026-10-17T00:03:07.321Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/2266794e6c25fa9ce51ce04a9bf4b7e330e3ac9351ba3fa14e487678acdb/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9221e09496fb942901993c1812ee87950cc0ecbb514c7c0f1f5fa6d78f701ed8", size = 1460227, upload-time = "2026-10-17T00:03:08.733Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d6/14deb28e5d8f328f626a0778ea2cf98fe908f2bbcccd804cb6f449ee1782/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7a5c38bdb93669c596f94fb7204dd6ebe4fadb19058ac9a2faf2948c2a175f96", size = 1562691, upload-time = "2026-10-17T00:03:10.25Z" }, + { url = "https://files.pythonhosted.org/packages/f5/19/7a28c0e5ba54f0d3e054f847a2a549b748cb179ab433321a8dc1b39e398d/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:acf4e9f055d75058ab01536da330648746f2f82b7697a85230547f2d5ebe895c", size = 1558669, upload-time = "2026-10-17T00:03:11.799Z" }, + { url = "https://files.pythonhosted.org/packages/18/9a/f9e2690eab58217f1d395a964a78efa7eaf813a1e38be8dfd9a8d45d0840/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87427913c325adc811e81e7c9a757b061f47ef1e29114ac479aa0953a2687a98", size = 1665071, upload-time = "2026-10-17T00:03:13.284Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f3/ef5b159a0d7161b404f05e7ec21cf7735ee7edc10c000cd2cbbc7bb6b69a/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:ad88921f95b92bc144d9ad059a0e16f8c1ae69f5239f2d3c6536fd2774f73dfe", size = 1473364, upload-time = "2026-10-17T00:03:14.563Z" }, + { url = "https://files.pythonhosted.org/packages/1d/87/6e8ac4a865124a12cc4f915bf1bcfac3bba426cd5f58d3712a873ee61012/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f8b17e2921e56621bcb46792bd3e0b82ca3b85de78d5936e7b8174ba1758b0aa", size = 1500566, upload-time = "2026-10-17T00:03:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1e/29155f1621433008013ad5c940a03d41a676be0680c41527040472d37583/ast_serialize-0.13.1-cp314-cp314t-win32.whl", hash = "sha256:2f9a9ce8b0994f757b941f7f7fe384a6666b4162b941ebc5c248081baea18e47", size = 1128674, upload-time = "2026-10-17T00:03:17.481Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/2506da65143ea138de5822e9173e0b72320a2f70e23bd723f044033175f1/ast_serialize-0.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5d99910e6b9a9b9360bcca2ae99aee36cc912ce058df2a57d1c33099b6cb2593", size = 1163306, upload-time = "2026-10-17T00:03:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/6d/8f/c7cb22638899b0cf15908809c270560e46395e65604bc59e4b623ab9f405/ast_serialize-0.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:a789cbe14d0a0e9ec9b32a456e5abc81ad8799479c3cfd88a29e3442262aebe6", size = 1133775, upload-time = "2026-10-17T00:03:20.377Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e2/b0c140fe50fa39dd249e382f7733af6f816c90021fe7959a0b026f86237f/ast_serialize-0.13.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:23d768369b89d551077dc173da6892fad00443338417523b6732f4e8ba4a7ed5", size = 1236160, upload-time = "2026-10-17T00:03:21.945Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/a8a2ca21e3943555b48221f8fba546877a7ad39efaf6505506524967d7c4/ast_serialize-0.13.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:68f8829d114dab6c9db4a8bbb8d0e342958faee1b5e15a29022678df639960f8", size = 1222214, upload-time = "2026-10-17T00:03:23.462Z" }, + { url = "https://files.pythonhosted.org/packages/15/19/2f58f2ba64e4799b39de85c140aef09a03aa6ea53be75dd292a9d9b9d8ae/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e385b17d5bde892a6184003a9aff3518387908b73f8f5ea539313c07c8c0ede", size = 1282979, upload-time = "2026-10-17T00:03:24.695Z" }, + { url = "https://files.pythonhosted.org/packages/b1/af/ca6087fe84210c1ccd43e3182917bb70649fa1e69745a90c1d59ba08fea8/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:02686b281ec9b8e9614f6f0db27e12e4cac515f336c5d05b678bc9eb73ff2af5", size = 1288596, upload-time = "2026-10-17T00:03:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2c/4ef964c763329a0347125e745ec0451ba4fbaceec9efe2ac729b7cd55f17/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8ab2c2585b2e22e48cd4aff2136fce4eab4ced569b85f150ef9a5a5d6f34d49", size = 1545540, upload-time = "2026-10-17T00:03:27.52Z" }, + { url = "https://files.pythonhosted.org/packages/bb/85/aa961dbb75813cfa94de0ce9a16a9510c226b95f604c405421e727f44bb0/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c652d9f5d52d8b81eff3ff2c95c9dd0f281de45f6ad5d3fa95527550c396454a", size = 1309374, upload-time = "2026-10-17T00:03:29.091Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e6/f2b67c327d9f9533e41791804403ccc2dc457821195a2e51f0b587a90b90/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:121e1d08d58b75c15491cc3003609cbad71ac2b0016043c36d30a6b2032b6605", size = 1303466, upload-time = "2026-10-17T00:03:30.451Z" }, + { url = "https://files.pythonhosted.org/packages/73/1d/33f18b81d1bb1f5d682943ea92fda8130a6593627f12f6201668d40824fd/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:30cf89b6a6844542d116c758725d13de5b97b9410a51ee577715f761270d0c1c", size = 1300576, upload-time = "2026-10-17T00:03:32.102Z" }, + { url = "https://files.pythonhosted.org/packages/25/cb/64914144925f1a4abdd9217e717b122255c38fc5ebe4342f92052f3bacbc/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:732aaae9e498c973fc63fd7f308009b57c4afa6ff59d0b674d894e57d0bca01a", size = 1355960, upload-time = "2026-10-17T00:03:33.676Z" }, + { url = "https://files.pythonhosted.org/packages/ee/80/bd29379fab2bce19adc0bf9177002b11b23dfe8d8fecf409998b458b4624/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:d9c1a920b2aa0c1e016bed7721689717034ca0dba64ac3a3ff243b003d4e8dd8", size = 1459779, upload-time = "2026-10-17T00:03:35.09Z" }, + { url = "https://files.pythonhosted.org/packages/83/89/012dbe733de05ffdf8e40ca0f9fa5d417cb16ec93a74463c57c001914198/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:847cad4c22953823f5e76d0b126c6f9da670cfa45b1df41b9324e22189d77611", size = 1563399, upload-time = "2026-10-17T00:03:37.285Z" }, + { url = "https://files.pythonhosted.org/packages/69/37/ee4bcb0358bee38744eb0f18560446572d095fe3bf6b63d6f739d08a839c/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:aca07de3ca8e3d9f2b716c3c31f4f384fe4bd2d03e09912417cff7a3e08036f7", size = 1558808, upload-time = "2026-10-17T00:03:38.711Z" }, + { url = "https://files.pythonhosted.org/packages/54/1c/ff44c5355251d61ef22934c4430bb31a61de4781d25a992154aac9b8c82a/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:5db12d843d931b9d1f1519ce66b9cbfe72d8563b75c1204d255322254d1964dd", size = 1666324, upload-time = "2026-10-17T00:03:40.376Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bb/6da4bfc8aa3403e6447607131bf3463acaf954d990c6c8b6787c1902ddfe/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:fb8021f4dc3296de18737e3b8b34994411da725374d74a0b9eb32a738f2a23d0", size = 1474089, upload-time = "2026-10-17T00:03:41.715Z" }, + { url = "https://files.pythonhosted.org/packages/21/97/897e33bef2eefd880e6350e375cc66bc5bcd00a139be480fd655f5b3f095/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:0d764913374ba21df8cd618045b371ed104ce55c6276de7c59e554a4f5ba26fd", size = 1501542, upload-time = "2026-10-17T00:03:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4c/821211ac83870c27e4412381b7c7838336456b40575bf840e4680b2018d9/ast_serialize-0.13.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:2bad302ae2427454ef9dab71bd5c3ad67188860c39d36daee38f72031edb39c4", size = 1126118, upload-time = "2026-10-17T00:03:44.616Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/2b2d5e6c4fa352874a80618e742ffd32d249dee6c119f4233b51afc6b8f4/ast_serialize-0.13.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:032b09d86c785c3984e975378b7861f2a68ac44e22ba7b07b93a536b2524eeb8", size = 1163766, upload-time = "2026-10-17T00:03:45.986Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/d610cfcdabaa44fd62a54ca1fc57a7dba120643798b089a14914973562de/ast_serialize-0.13.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:929eda0c419e622ab49df3a4c45f81c57dcc10874bbad55d0470a4a03324dac3", size = 1134043, upload-time = "2026-10-17T00:03:48.453Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/0e30f9eddf7d44ccf9d25979030754fd0457af61fa4279acb8c293a04f97/ast_serialize-0.13.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bf53a3ba1cbe45399fc1e4ccd684b8f2a11cca450f89706b79122f478534c6f5", size = 897546, upload-time = "2026-10-17T00:03:50.093Z" }, + { url = "https://files.pythonhosted.org/packages/81/92/d705be88ff452b9ba0088b554a0cbd308fd01879da092b33fd2ad7d1aa60/ast_serialize-0.13.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4bcf1d5f4eefecaa325a34ecf079b2741189e7d0821523f92cb343a389a67489", size = 1242416, upload-time = "2026-10-17T00:03:51.521Z" }, + { url = "https://files.pythonhosted.org/packages/60/c2/98370845a7f35cb8a25782e78284b48e01bf3128339881d48d613f304f8f/ast_serialize-0.13.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1b158f1c9e3a0d386913924c57995d6df78224868a4d0f215bdd41587c97b34a", size = 1229729, upload-time = "2026-10-17T00:03:52.825Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8b/2d7ffed76addd9d3fcf37fb171400942735c8912264c763561a85407f6a8/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a6201b69ce42e919f2fd9a559138d5410b5d8afff7c3a6b1f500b0481ff7d8", size = 1292255, upload-time = "2026-10-17T00:03:54.148Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2a/bb9e0e719d89cc950fadfcfcc9cb4bc7640419c4228c852f031ab8abf440/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eb0be64c9b8f1ed26637eef345abe75ea90351c92c85a8aec7838b3af971011", size = 1298501, upload-time = "2026-10-17T00:03:55.559Z" }, + { url = "https://files.pythonhosted.org/packages/7a/32/13ce07dce48226f5bd9cfd8f708ec101a8be05aa430cda4918ce31ce8c82/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f2427bddfca208e84de700044fadf9aef2769a70bae740a1bdabb89ee9e1117", size = 1553758, upload-time = "2026-10-17T00:03:56.952Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fb/9b51d186db0c20ca28cbaaefd50f03b573c89327b9a2627820cbce22196b/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88df960e67184a71874e5b2ee014f87d1254a0dbc1f903c7bd7b1ab7733ecaef", size = 1318258, upload-time = "2026-10-17T00:03:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fa/da311559dd3dae9af5825cf21660775a929b970f72e3786af3f2cd667944/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f99786e0d62082a5b121b3437f3167562133711df22128de1e9886f57f0b226", size = 1313260, upload-time = "2026-10-17T00:04:00.194Z" }, + { url = "https://files.pythonhosted.org/packages/2b/11/97a4b9e5b4776d72f47fcd9a09a497bbf68ec8c1ae074307a2c51e410d85/ast_serialize-0.13.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:a01b515b25a03184eff4acef25b981ae007916379682e908fc8785b342a0bf3e", size = 1309992, upload-time = "2026-10-17T00:04:01.575Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c4/62f85121a80cd451ef04f550cf0f7ea0ad9ff46cc4e9a5b9ae439b6527b1/ast_serialize-0.13.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:29422d7bcd9e7f39411571c36cec45ce8a7a9b3ad4b2e7aa64497c15a9e397f6", size = 1362715, upload-time = "2026-10-17T00:04:02.881Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/c5f4a545f72a7681e3295bab6e3600ee0862ec4391bed961fe9f735b67e0/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ffb0f548a7dc126067fdfb7b2325ab5dfcdeecc29faf5e0f0461c96d6ebfa3d", size = 1469931, upload-time = "2026-10-17T00:04:04.181Z" }, + { url = "https://files.pythonhosted.org/packages/38/a9/65aed4f8fa7a001a7cfdc03e68e844517ab041ba6fb614b7d78a1d6ac056/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2876444a5f87a7c76f371af3c07c2fda563a08bc8af52568df812e8aba4bf568", size = 1570945, upload-time = "2026-10-17T00:04:05.778Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8c/44b2da991cede79bfbf52089f33a5f0168945466b3d9cd38b5dcf08e7130/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:885a62ec6481ce58ab24c6357360d7a82023b62dd7e5e7ceccf59e9662a3272b", size = 1569978, upload-time = "2026-10-17T00:04:07.347Z" }, + { url = "https://files.pythonhosted.org/packages/46/e4/109b2a5beca700237767d5add920c2f8e1ba9bd5799b0faf143d0e9cf4ef/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:2015fd0165e463aac3b1aadc6a1fa1e5820c14ca950821f4fe02f8a3f6a388d6", size = 1676082, upload-time = "2026-10-17T00:04:08.7Z" }, + { url = "https://files.pythonhosted.org/packages/73/e0/7df70f6dbb11b5762da5416318f08316a1c51348e0d69ec79ca5f98f836a/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:eb41e1906502ac80d24ff343f45008552a3f5b5158607959fe6a07dbfc6eb037", size = 1483207, upload-time = "2026-10-17T00:04:10.162Z" }, + { url = "https://files.pythonhosted.org/packages/1f/bc/523aa1c08bd69b5654621beb3dc1a9035715a4a63762fb8d219d6fb3eb81/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ca9acc67f796aa2554fa3037dccc022a4b08b38b2ad153edfac9c9bb7cb1f3c6", size = 1512140, upload-time = "2026-10-17T00:04:11.589Z" }, + { url = "https://files.pythonhosted.org/packages/66/83/6607060659998f00afdea0d8375c1e6e2b7d515ab905090d9a5a02f48851/ast_serialize-0.13.1-cp39-abi3-win32.whl", hash = "sha256:4f98da562e5901d69c7dd30885594c5438f859edf354e9ac7517d188fb026c3a", size = 1131889, upload-time = "2026-10-17T00:04:12.953Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6f/bafd6490fe7b355627fabe95f4417e3d02da93102c8552550eb9a0774e1e/ast_serialize-0.13.1-cp39-abi3-win_amd64.whl", hash = "sha256:ddf7cbc9c8abf70102c4a088efe154bb86bfb825b0a10e556d2b15972cf99bd7", size = 1171153, upload-time = "2026-10-17T00:04:14.362Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/4235f4b375a67a37ac595bf478a5407a744eaf7b64113c1c3a5432b8d954/ast_serialize-0.13.1-cp39-abi3-win_arm64.whl", hash = "sha256:48f11d96c9ec71119612f8e79a8c55c12f2b136716d68347433c561d27a25f43", size = 1139035, upload-time = "2026-10-17T00:04:15.767Z" }, +] + +[[package]] +name = "boto3" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/b4/da0e5fd912f51d04e9f019fea50cbb859a4ba07947c6e28262ff59c98360/boto3-1.44.0.tar.gz", hash = "sha256:5d311d2988ae560f83e5fb8a284cb57b48e2c8d444ecb78a6d6752ae1740e04a", size = 112741, upload-time = "2026-11-10T20:22:34.536Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/53/9c2ca547dc9fa1e9e2a0fec0096b9e25277f6864faba580ace75e0db5585/boto3-1.44.0-py3-none-any.whl", hash = "sha256:ae3009db1294fd4ca2e30d9592ead8ab56f0e270bcad0b95e555f03f7875716d", size = 140153, upload-time = "2026-11-10T20:22:31.736Z" }, +] + +[[package]] +name = "boto3-stubs" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore-stubs" }, + { name = "types-s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/16/2abb69eb953e2951d9685651089bbe1ed4cd6716633c1c7160656c2d0c47/boto3_stubs-1.44.0.tar.gz", hash = "sha256:ed70d60279f9af355d6236b8b8fe42664c0ee45af0be703e42d9b1a3123f6667", size = 105727, upload-time = "2026-11-10T21:59:53.494Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/64/79d9ee1e3a29dd3f4334b193baa6f891223782dd5d33bdbeba5aedc2c245/boto3_stubs-1.44.0-py3-none-any.whl", hash = "sha256:1cb3225a88782757935616f1ca79b66524f40a3c5d53d864c7529081c34e43bd", size = 72068, upload-time = "2026-11-10T21:59:47.819Z" }, +] + +[package.optional-dependencies] +batch = [ + { name = "mypy-boto3-batch" }, +] +cloudformation = [ + { name = "mypy-boto3-cloudformation" }, +] + +[[package]] +name = "botocore" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/92/480a2c685a9b02a947541a217ae615d5e78f287d833cc1b9f23dfc39e313/botocore-1.44.0.tar.gz", hash = "sha256:224efbd3ee978856da460585533ebf1b97831960e575857ca2424f33e554917b", size = 16577303, upload-time = "2026-11-10T20:22:28.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/0f/82dd92901fdc21a3444eba19e23e0a8584046f8c7d6f9ae171e38ecd7dda/botocore-1.44.0-py3-none-any.whl", hash = "sha256:9055745fc16400377ac7e825ac63b8f77af17305e1c77c9a5519a648d7b2e87c", size = 16277656, upload-time = "2026-11-10T20:22:24.273Z" }, +] + +[[package]] +name = "botocore-stubs" +version = "1.43.126" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/7a/717e3dec2e4721f76621ddb397b4035ec1aff0c174b80f5c439512a352e9/botocore_stubs-1.43.126.tar.gz", hash = "sha256:15b14ba9066f6eec50a5861407fede151b4395e986036c8d564f4b908835f820", size = 42882, upload-time = "2026-11-06T22:57:14.723Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/23/10b0f6ce12d3d85cfaf24d2e420f76e8e744970289e447d43846e51db32d/botocore_stubs-1.43.126-py3-none-any.whl", hash = "sha256:07f1b96c89d787b95ebf0cc5f22fc2b7f61c179e704c289b377505d8d61d5d49", size = 67248, upload-time = "2026-11-06T22:57:12.732Z" }, +] + +[[package]] +name = "click" +version = "8.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/37/edbf1b0707709013c0cf2e8317e03d97ed29bf4cac709fe328904ae92a38/click-8.5.1.tar.gz", hash = "sha256:9bbfada76703fd1d784b83eea012e3e8c6a001a876543ef104fa56aaaeb072b4", size = 399151, upload-time = "2026-11-06T17:37:01.021Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/7d/164b7ffc8624fe9efb4221bf9af485d5c1e41b9ae118045d2b6dc5457d8b/click-8.5.1-py3-none-any.whl", hash = "sha256:8bb5376d2006950386f8c6427613334a2acadc5f559cb80e638d00b4df957e37", size = 126163, upload-time = "2026-11-06T17:36:59.287Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "hls-submit" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "boto3" }, + { name = "click" }, +] + +[package.optional-dependencies] +dev = [ + { name = "boto3-stubs", extra = ["batch", "cloudformation"] }, + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.dev-dependencies] +dev = [ + { name = "hls-submit", extra = ["dev"] }, +] + +[package.metadata] +requires-dist = [ + { name = "boto3", specifier = ">=1.42.34" }, + { name = "boto3-stubs", extras = ["batch", "cloudformation"], marker = "extra == 'dev'", specifier = ">=1.36.0" }, + { name = "click", specifier = ">=8.1" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" }, +] +provides-extras = ["dev"] + +[package.metadata.requires-dev] +dev = [{ name = "hls-submit", extras = ["dev"] }] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "librt" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/cd/ae5e0e9dba45d1399aa04a5395bcc0bead40d9fa06dc903634a7b4d7473d/librt-0.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5e6144e68b577f157519f2ba88ca20e3ed61c29b00e5cdfa76cd2d45acf059a", size = 151036, upload-time = "2026-09-29T00:53:06.284Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/48a16e323c5f9447a94cce7b59babf60fa04e62c3365ecf060c77ed8b320/librt-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:33f41443a1f4e1f099331b3d8120e409fbff84b9760bc1cc9ea496f37ddaa5cc", size = 155211, upload-time = "2026-09-29T00:53:07.72Z" }, + { url = "https://files.pythonhosted.org/packages/3f/29/0f59299eb4251a409b2e690ad4b7d9f8a676db829d7817ec961f32b44f7e/librt-0.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e510b7770bee609617a3374a96548eb114cae048023e3f049ee449e7ff2db32", size = 502587, upload-time = "2026-09-29T00:53:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/de/ba/d6fb4ef8d1537c396079d72289f16be7cd35a366e5065c51253fea2760b6/librt-0.16.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:efc49c462d4516b8a58b00b490078fa64689fd1fe66970cc190131d7afb8027e", size = 496146, upload-time = "2026-09-29T00:53:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/52/fc/8c50dd4d7cc97c0ee8f252c8a3104980f234391cf1519b554e8b9de08b60/librt-0.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:92caf82ebef5e12d21c72242b70d1e92536f1711cf2a727a4c276de4b4469087", size = 513336, upload-time = "2026-09-29T00:53:12.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/59/16c409c56f708eda2db9a0553662845d45e3871c77d70a240dae3f3bdc56/librt-0.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17bac7f7a16b328fff77e440287693eb017abde913595b5827ebccbc21ecd8a6", size = 531689, upload-time = "2026-09-29T00:53:14.39Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/d34772a6c29d1446dcca6e64d74062efd508523ab351aa16625a4689d5cc/librt-0.16.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5b976054553670829985ed767feb78fb6bcede0175327c4844dd5c281c1be659", size = 524537, upload-time = "2026-09-29T00:53:16.064Z" }, + { url = "https://files.pythonhosted.org/packages/77/8f/24c5631313746131ccee53bc91fdc8374f9cf25e0082a1fee9c93bb98acc/librt-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0058f9d68721094105917254c72ac0569117bb7b13b9769cf45d26d89f9d21cd", size = 543231, upload-time = "2026-09-29T00:53:17.939Z" }, + { url = "https://files.pythonhosted.org/packages/f2/cb/5f8e0d41dbd8b499c2265e939c31acc9ba59845565bf99539ad1c06aebcf/librt-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b7beaf3f4487b7d8adef1f158b49067cb4d5a19fa7a3bf31a4e7a820e435c5", size = 546499, upload-time = "2026-09-29T00:53:19.666Z" }, + { url = "https://files.pythonhosted.org/packages/be/61/063052de441d1385f59cea4223f184bf9e5d125de1ae3239b490aa1e486e/librt-0.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:468df902df016a06eb0e40b0747dc8d14e47d7a38b18b63b1fb167d85cb94d63", size = 555261, upload-time = "2026-09-29T00:53:21.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/11/a2ada0529372268d6401afa9d457a095b68cd7753532b6f7f33049a19b43/librt-0.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:aea7b1f2b125dad5de85f049136651bff256c883c65e6b9209b2da0a1ac3cdef", size = 536029, upload-time = "2026-09-29T00:53:23.07Z" }, + { url = "https://files.pythonhosted.org/packages/23/9d/5bb6d38853382986dca702fc7e06c8256d30f5fa0676d882773b744610dc/librt-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a8afb6557920860b7a3a596eb804cf37e09e7cf8a803db2478c202acc72d8c2e", size = 573748, upload-time = "2026-09-29T00:53:24.696Z" }, + { url = "https://files.pythonhosted.org/packages/99/f6/0025cde35ff7f607684dc775a2b2d732cce2561cec050a6ee1fb2e1fc6fe/librt-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:77c7a2b4fe2c1369e0d5aa1cade26740a7b14be32fbc9a5535d617d20065c39d", size = 79861, upload-time = "2026-09-29T00:53:26.089Z" }, + { url = "https://files.pythonhosted.org/packages/93/93/303b8592909bd583f83f02818ffbea3f7647ca1e22b1cd5465d04b8145fe/librt-0.16.0-cp313-cp313-win32.whl", hash = "sha256:02d89c813d5ff74b17df72d3a34819d132cd168e56b81bf755b809bd9e46b8c4", size = 106236, upload-time = "2026-09-29T00:53:27.43Z" }, + { url = "https://files.pythonhosted.org/packages/cf/24/80bbb463c60ed18e29cb26aba386ddb76609580e4e7160710e550587018c/librt-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:14ed6ebe3e4f85f326d7920011ad30ff49ed9334e62cf88caef9ba973d9e3a92", size = 126180, upload-time = "2026-09-29T00:53:28.7Z" }, + { url = "https://files.pythonhosted.org/packages/43/80/b1a6fbdd7da825cdd55c71aa81eb6cfa82c513c360774152eacb50b4a771/librt-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:83d4041a3d9b2fd053a8a4e1f22878b3e5833e2712956382d5c048d791454e91", size = 116557, upload-time = "2026-09-29T00:53:30.012Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/9e0cf7da129a93c3dc7f45bc3cd4a660f2aaa995aa8a6f95c2583ef41239/librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1", size = 149844, upload-time = "2026-09-29T00:53:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/8a90d2a8f2b2e471bb486b7aec117b8ae622715ed6c39853aec48ea20073/librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11", size = 154111, upload-time = "2026-09-29T00:53:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/35/ce/67abb46258da4d3e42ff5b141db6f38c59357bef84c7979f183b22f924f1/librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2", size = 494234, upload-time = "2026-09-29T00:53:34.466Z" }, + { url = "https://files.pythonhosted.org/packages/12/f9/ea7162414a16f8f1bbd3b493ad6d22b926c5471916e078350bdbca8c4e5f/librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96", size = 491173, upload-time = "2026-09-29T00:53:36.124Z" }, + { url = "https://files.pythonhosted.org/packages/b1/09/9b3e869060dd33f9989b80ba4fea306f6db8cecefcdfe6d7346ef0603f65/librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86", size = 505547, upload-time = "2026-09-29T00:53:37.686Z" }, + { url = "https://files.pythonhosted.org/packages/50/07/79007d2165f649ea93e08c0962d1d62c70af9cde77965255095bf9d96f9a/librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667", size = 523057, upload-time = "2026-09-29T00:53:39.301Z" }, + { url = "https://files.pythonhosted.org/packages/61/0c/8fbaff66d0ba376d8864653f5acce1569bae27e89648671f26f7eca67ab8/librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b", size = 515158, upload-time = "2026-09-29T00:53:41.012Z" }, + { url = "https://files.pythonhosted.org/packages/df/2e/23ff0dece76f07a4124413a57682efa0bbeb5765ac0122bc0955513f82ca/librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311", size = 534183, upload-time = "2026-09-29T00:53:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/e11dea21d9a29486eba78887380374189d472734fa32cc50cb37ca44d3d0/librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a", size = 540663, upload-time = "2026-09-29T00:53:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/75/e873ae158a8b7f5359be33e7fd6c1fbe02d9a78a3e89a77de6b0e837f476/librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd", size = 545908, upload-time = "2026-09-29T00:53:45.812Z" }, + { url = "https://files.pythonhosted.org/packages/ba/36/8939d3f6a93e11bd9592e6fe28d2b44f1c2dc4bed6e22e72359a91e18ffb/librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55", size = 523302, upload-time = "2026-09-29T00:53:47.596Z" }, + { url = "https://files.pythonhosted.org/packages/71/14/35309f44a077f0f42ade0e2e7cd88c0cea760c661af205c01ea90d3c0e1f/librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152", size = 565911, upload-time = "2026-09-29T00:53:49.278Z" }, + { url = "https://files.pythonhosted.org/packages/78/0c/df6255b94967f3159ebc46f08d8e783c12da6ee269ddb74b2efed63c640c/librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839", size = 79119, upload-time = "2026-09-29T00:53:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/6b/44/d30d5a5461378c9d33f36736c6791c3b4c4ba4b1ffe0da7350aedcb2c9a0/librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7", size = 100140, upload-time = "2026-09-29T00:53:52.205Z" }, + { url = "https://files.pythonhosted.org/packages/c2/98/769712f356a1e897df3581bb0c3100375d054a000de26099360ea65b5111/librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41", size = 120464, upload-time = "2026-09-29T00:53:53.495Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/ae2abccc8bdc8b063c1613a77686e17b74e9d3d60cfe6f12c63fa2821b9f/librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99", size = 110889, upload-time = "2026-09-29T00:53:54.817Z" }, + { url = "https://files.pythonhosted.org/packages/e4/26/0737d4be058dd6376eade7dd8b380d4b869b6394cee929393a5a431c45bb/librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc", size = 159838, upload-time = "2026-09-29T00:53:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/01/96/9bc96531d7c620e9949af904470f02e3fa8f35129ab8e8f281c51eaa3788/librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a", size = 162018, upload-time = "2026-09-29T00:53:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fa/b0289dcb186eb3f97221ba00da5f4bd3ba7fa5e99752d48aa8d336615334/librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0", size = 703983, upload-time = "2026-09-29T00:53:59.024Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ab/05ebbbde7530fc5eeb58fbd1581522a9f64f132fa703c4c595eed6e14760/librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9", size = 683847, upload-time = "2026-09-29T00:54:00.671Z" }, + { url = "https://files.pythonhosted.org/packages/a3/75/f52aeecd4dbadbddf80725ba7de126d8bd5d0eb66247eae17a81ed90dd4d/librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188", size = 698045, upload-time = "2026-09-29T00:54:02.247Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/fa277a835cd6eb42380591ceb85f48c5b4d2b2d7e2cb9869e1e17d24d237/librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048", size = 724640, upload-time = "2026-09-29T00:54:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/e4/2cf64354f3fde8ebd591b3b48f96510bee24ac5f7d1e567adbbe210abdbd/librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143", size = 731748, upload-time = "2026-09-29T00:54:05.741Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c9/c180af3e94e01aa529fa93d7733fec2abc47f222345400cf21d5481d5f8a/librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04", size = 754960, upload-time = "2026-09-29T00:54:07.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/d6/01073aa78c58f356b10d9c57b3fe9abb143df338142bcd316df417b89db0/librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e", size = 747000, upload-time = "2026-09-29T00:54:09.06Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/1de3783908658d697a8cfc00582f61299ffba7796d7260c112e4700b1109/librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b", size = 749569, upload-time = "2026-09-29T00:54:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/e817f66c96d6caa8bb8435ff93c4c220624d59efc506be59ab98fcd01d0c/librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302", size = 729483, upload-time = "2026-09-29T00:54:12.25Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c9/23992ccd2b9d22798fdd0f61353183a47414e651da782ad83eb680f50833/librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1", size = 776584, upload-time = "2026-09-29T00:54:13.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/e8af957f08e6e2e8e566b099e43d2748418aa565df6ee1d9d3331209fdfe/librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7", size = 104402, upload-time = "2026-09-29T00:54:15.568Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/946e6443d7cd32347a086043395e421e52fd603c9163d2ec970ceab8eed6/librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d", size = 126227, upload-time = "2026-09-29T00:54:17.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a3/bc4f9959d3c62bcbf9e5fd3470a8bbd8bf33224ed4c25d7fba173201b8ac/librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b", size = 116493, upload-time = "2026-09-29T00:54:18.567Z" }, + { url = "https://files.pythonhosted.org/packages/b6/4b/10fdb42dfab4c1533e1570e686b18e86ff4328b406361b39fb3016667638/librt-0.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:6fe436af2eaf630474f491af5d032cbe45f93fcff5c3b9fe4ab194a7255b20ff", size = 149829, upload-time = "2026-09-29T00:54:19.933Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/a90ca13f1d3d91af1680000a4038536907018fbd51764767287a05d28b8b/librt-0.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:8ff5d26c529336be9bd7ae04483235d77778ee7d6444a95353102b542601ce81", size = 154522, upload-time = "2026-09-29T00:54:21.306Z" }, + { url = "https://files.pythonhosted.org/packages/5c/dd/bcf364eacfa070bb1fc88d503111ae7177914197ba1fa717c748926e7930/librt-0.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:909d8e3c1faee44cb762b1c519ff8613dcc5ceae5c99987a00917b5a31fd1d6a", size = 497593, upload-time = "2026-09-29T00:54:22.798Z" }, + { url = "https://files.pythonhosted.org/packages/18/c1/2c4e81e347bdabfe8346bfc6dc37ae5e154d61c88e30848ec0606025a5e0/librt-0.16.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:6d4a64283ee61824b5790de882bc68e2d9d7a5143537cb7a966f7354f71646d4", size = 480459, upload-time = "2026-09-29T00:54:24.364Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d7/fef2a3cb8400701be496f6e459876f650b3451f407e30cb243de571ae615/librt-0.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5810ba811297fdf37a1531a57667cb8ace0842013ca8606bf9eb7c24cf4be154", size = 507814, upload-time = "2026-09-29T00:54:25.997Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/53762927a32e9dc9eb1d1c1f3528da281290c86d96929a8af652f671266e/librt-0.16.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e56aaf8c167548dc8e5d6f3bd0f48dcdd299a23c73be3f744aab79d99e9c7f5d", size = 525114, upload-time = "2026-09-29T00:54:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/6c/67/0b9d031f303c4e8c691a9a8ef9d272f13b530c11cc563b819df621b6a348/librt-0.16.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f36c58e33b304b525c6c9c5076399c6ebf1109e17b9051a05a407b091b9215b", size = 520465, upload-time = "2026-09-29T00:54:29.331Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/2056a3a85864e882eb17a203a10ddb26fa748bc9718ec67e79059ab46cae/librt-0.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:242e00b3d4fa37c3d3c1ca5f5c9adb7d909ddb1eac9c41f2787320d00caa0af2", size = 537257, upload-time = "2026-09-29T00:54:30.915Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/e0d79790c163cbc0909e209a6f62e30bb713b647f905a176cdc64848fd4d/librt-0.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:0253721561787b8df8443eb347b7a6461015354e5bdd37ee38a41fef220d2bb0", size = 527442, upload-time = "2026-09-29T00:54:32.478Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/1c0c95aba7af51f4752ea34fbf2eb79b36e7cae3a72536248e8c735de735/librt-0.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3e483a8d69ede8067db70c0e83007423b6925de6fd53afed01d66160f2e9398c", size = 548139, upload-time = "2026-09-29T00:54:34.183Z" }, + { url = "https://files.pythonhosted.org/packages/98/91/a8a43dd5138d4f55f88846b8f0c85454a4fad69952cebfcff9948f831290/librt-0.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:69ba927445cfaaffb4081003ef5224c55a5c2ab67ef956f416ef744916e44121", size = 529708, upload-time = "2026-09-29T00:54:35.761Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/d5226881ab2b7c20d9d587b37bdd4a0ec8775a96d00ca87ac9f385587db4/librt-0.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d6a365f2ab45a984d0e00eee0dd17f599ceab8cadab6ea07b6111c8132fc0e42", size = 567781, upload-time = "2026-09-29T00:54:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bf/2345ba57a626e8c78c4ddcc724636a8df5a593fe46c1ee76bbf477e32b0d/librt-0.16.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f01f3805f2dae4781c0c34b440e31740d082950bdaf89a6f601ad589a28af57a", size = 79177, upload-time = "2026-09-29T00:54:38.792Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/99e77936cc9207f629b077bf7cbc5e2f0827cddd7c29792f04ef881bd2c3/librt-0.16.0-cp315-cp315-win32.whl", hash = "sha256:b0e3e721c75d2e79a76d4422c79d7ba705fe1bbafec907037fe7a657a480a0e3", size = 100127, upload-time = "2026-09-29T00:54:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/56/1e/801fe26bc622061b9dfd010e166d94142cb774b733217e6d98d1c0cf2638/librt-0.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:bc02954b1295de798bbdb0b4e2d8a28c2117de8b5c73dcbeb27dc32572dfb971", size = 120480, upload-time = "2026-09-29T00:54:41.722Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a9/d533983055bd36e112627384c2c038845d1df882540b8bcefb566475640b/librt-0.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:c5db585d43449a5f54303d4b2774e45e1babd975cfe1630a3d708c0b80c3e560", size = 110915, upload-time = "2026-09-29T00:54:43.052Z" }, + { url = "https://files.pythonhosted.org/packages/55/fe/d62238fa9c653b0e0613290349467cb65711b5800e8e474f45e942a0ad95/librt-0.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f06c689cb14afd9b612727553a5ec5a40febf113ca41c4413a2b0b334285884b", size = 159714, upload-time = "2026-09-29T00:54:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ac/f31efe7818700be72ba4f9af8a80fa67c39808c8d26dacc55dc1f6f35172/librt-0.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:13b4e8aba90b0b1c82474e9844aa9ffe7ad3faa484350e1da64cb8188d903134", size = 162086, upload-time = "2026-09-29T00:54:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/d9/16/4d7487bf86a9d7e8e18f37ba5538789233ea693637379b75701bd35ec9de/librt-0.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a269c46ae327d8e6f8c1f85f7516cb52c0fa48127565a1105a4f4a05ff2a0b4", size = 712727, upload-time = "2026-09-29T00:54:47.486Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/50cd550ccc1a517b9ed62347625c01ddf8c4afad66490312677c011458f6/librt-0.16.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a33e0dae1f8592146a4764d54ce842b278732d21a84e17c3bbe6b1bc158a2248", size = 681106, upload-time = "2026-09-29T00:54:49.126Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/7293f712975ee6fdd2251411fd9ef1c62bc99c7b83ecbe62dbc999b1fab7/librt-0.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47ada6ea32636492c61aa8ad27ae3b9404bfe7a97e3ba946d1984236cc741da0", size = 705921, upload-time = "2026-09-29T00:54:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/67/f7/8aab946f11d59d1bece9ffc65d994b200c789a8ca5a95c17e17e609f912a/librt-0.16.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43bd6e642d8a248c114327f98dd25ac5a7cb5aa168ef02f0559b91874df16b8", size = 731786, upload-time = "2026-09-29T00:54:52.584Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/1c42ab31e7cb8384ebf6d3af607213c495222474f4940443ae7639ab7685/librt-0.16.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3d1bb7841a816ace6449bb26d3f9560dbfa20e71c568d23f0f62bf1e68f50b1", size = 744964, upload-time = "2026-09-29T00:54:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/4b19982d933d2dfced671e840b219e4c1cd3f507df6e6dabb51bbd4e3850/librt-0.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3931f7a3db322e7f44e02a280e3949326ce9579ad388ee8d691dc7c76da9fb70", size = 765305, upload-time = "2026-09-29T00:54:55.815Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1b/e872583de2dcb3ac7746e7a2321aeeb168274f3952a87dc66e05ddb29faf/librt-0.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f4462528b6000afe8f16907b5c7c2553abf1df005ba5140e6eb394541c3624c3", size = 744959, upload-time = "2026-09-29T00:54:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/10/de/a18c6bcfb297af2674233e90b3c3661c3a0af0f1434a0c966d2ef708a835/librt-0.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:80039ba9b6a7d5f1a0175a4cca6bbefead87bd854c80abad1cb30afe47a830db", size = 758138, upload-time = "2026-09-29T00:54:59.484Z" }, + { url = "https://files.pythonhosted.org/packages/22/1c/0df1d732539c297bb1a093e1fe3204d2faf76a9022cf4e1d1c2fce059790/librt-0.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7a1d272724b581bb6bc769dfdafed6da2ecc9886ba2450311de55a4ac2e1e9cd", size = 744268, upload-time = "2026-09-29T00:55:01.179Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f7/ccaf31331f20c91a5bd9bd48ffc3f743f9c81bb5720cc7b2a720ca204f0a/librt-0.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe4fb8c5445f7496d7f7f6bb0807875d09d47e6771ffa175fb2df2895fb86ba", size = 785783, upload-time = "2026-09-29T00:55:02.938Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ea/7421d9e6db894cd6cd788b604b3394a163ded6b942052b6f96c23ccf08e1/librt-0.16.0-cp315-cp315t-win32.whl", hash = "sha256:375bfe6b572a8f6cfc398709356046173bf27e64c4c5edaf5f7062f051fb4bf9", size = 104355, upload-time = "2026-09-29T00:55:04.533Z" }, + { url = "https://files.pythonhosted.org/packages/85/6d/7c31a506eb847bc58aeb2402d58e17605e821f68ab5df5e66fe274a6df41/librt-0.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bd3150023d3dc2bc70f3784e59ffa1140d56ddba3d8125b3d6f9f85221279bfc", size = 126134, upload-time = "2026-09-29T00:55:06.017Z" }, + { url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391, upload-time = "2026-09-29T00:55:07.484Z" }, +] + +[[package]] +name = "mypy" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/3c/78ecece71ee4b5445389a4ccd11627cfb9c69e4de554d11060171f376305/mypy-2.4.1.tar.gz", hash = "sha256:63b590965691e540a335fa74dadf31a7069142f6b72e234689bbd244edf55623", size = 4066728, upload-time = "2026-10-28T19:23:13.286Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/65/cd19bc06b67d2df4828220245fd98569ba40fac00c60ba48bef1817d8ac3/mypy-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f1613bfe6726169a5680ec3dad5fe1f6d96285c12aded51ad432eadedd39d45", size = 14214179, upload-time = "2026-10-28T19:22:27.49Z" }, + { url = "https://files.pythonhosted.org/packages/c9/db/4d54ca4f49a0113d0c956980272aefdc6eec5458f7a72b350785b9ef2249/mypy-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:680ba5d9f0e3fe0c746b3d14df15f2a718e4bc308a1145616a1ea691ce7c354b", size = 14376217, upload-time = "2026-10-28T19:21:08.019Z" }, + { url = "https://files.pythonhosted.org/packages/a2/45/089b4724b85f0f408941be5298f817ee89181c1ce031a73d60ebb150c2c7/mypy-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb70621535c22fd4b1a0450d37ed1d6c5eb39656a1cebfb189084c12de54ba7", size = 15467623, upload-time = "2026-10-28T19:21:21.555Z" }, + { url = "https://files.pythonhosted.org/packages/27/4c/15edbc64b0b1412bfe94c74192b3b56554ab642b85a31bb06cef6313101f/mypy-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4af5ad558033bf71a44a1be149ed61633522824f84c1b6bc2445d3fe2d2a5296", size = 15729867, upload-time = "2026-10-28T19:21:37.557Z" }, + { url = "https://files.pythonhosted.org/packages/76/5b/7d160fb685994b65b0ca86d352f3afb5424f96a9e6914719f7b861fb6701/mypy-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:acdd82cb3a2eb10720e87706a128d75785d005a8c47e36a4973221524f41fc56", size = 11390802, upload-time = "2026-10-28T19:22:18.081Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ea/453d27f8d531f57c5f14aa73094eaa48e3fb77f680bdede2045cb64fa58b/mypy-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:133886ef1a7a8cf673df55d865e41580c6c46aa9b8e31e1325dfe315c038f745", size = 10950427, upload-time = "2026-10-28T19:22:59.003Z" }, + { url = "https://files.pythonhosted.org/packages/7d/75/282d43d6fea7be984ac63e75ffd8eee146ba2f3b077bb1ca5bf3f973e0c4/mypy-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c45485061ba08f700acc12cfc8b177e6997e6bc353605babfc0abb081e079456", size = 14233251, upload-time = "2026-10-28T19:22:44.977Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a8/9021263cc7e761451c7d8131bdfab3c0a5c2d67aa2d694670019e1ab86a7/mypy-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727890a32f1858c09549eb55a8b234fe990d460450b28f03e8213ef925f1247c", size = 14394953, upload-time = "2026-10-28T19:21:29.492Z" }, + { url = "https://files.pythonhosted.org/packages/16/10/4192a5cea09ebbc41c534f57bd0d8f0fb42a8e810632cda6e37f072c59fd/mypy-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:beb254fec368c4e7f7f82a5f310c6470b893ec88a90b66426da971c6826b0453", size = 15442824, upload-time = "2026-10-28T19:22:42.068Z" }, + { url = "https://files.pythonhosted.org/packages/99/e6/d3b0a7474aa4f696594a47a218b6796d9c4346fc81ed288904278cddd9bd/mypy-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ec416d5d2ae689e681f5727e328723f18d8b5dc8c903a146b7851dc5727aad8a", size = 15717227, upload-time = "2026-10-28T19:22:54.026Z" }, + { url = "https://files.pythonhosted.org/packages/48/6a/2ec530dacd4f77ca50d0fa05b854098f2d75e5d18c587d2612b44f10d68c/mypy-2.4.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:0e95ad0bec03ced9a73b42514208abd24c789e67f0e1eba476e276dd7a2dea3a", size = 7898637, upload-time = "2026-10-28T19:21:41.709Z" }, + { url = "https://files.pythonhosted.org/packages/cc/56/de9d7bad12e3868d5f1f4279aed20b1b568b2c5e166f2d3a05b211dca0b1/mypy-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:51f742d4154824837ca6e92aa81f00eec222700a3c337bd258e5562a3880d75a", size = 11576656, upload-time = "2026-10-28T19:22:13.232Z" }, + { url = "https://files.pythonhosted.org/packages/b8/12/4fa9883b9b59e5c96ff62845ad019dc47786031c45d67eaccf452cffffd8/mypy-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:f44dd4c5ab22df21206dd926c98381ebdbf5bfd696df37dd38ded2d23143fcb6", size = 11174403, upload-time = "2026-10-28T19:23:01.346Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f2/827e356fc4f74c60b8da42663336e035b49d948a3c11224b0fc7543086b1/mypy-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2875280687e64740e6ddcef43bd4c5852ad45ff34549740f6aea3f0373870b93", size = 15713427, upload-time = "2026-10-28T19:21:26.757Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/e58a4fcae1d238189eac758036c0921992a86e3e6209190efd96d61fc5d4/mypy-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea0714d2bccaf6d010c970e1d3ba2c9c2add79d2030aad2ef03167d788948276", size = 15938481, upload-time = "2026-10-28T19:22:34.371Z" }, + { url = "https://files.pythonhosted.org/packages/a1/54/a20f881801b88900923f2be1541a4b438ad0f0883e03836641fdc3d7adf4/mypy-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63c6ff0e7ea546943001a91639c9f0c64b60d239268fa5d29e4a6c87f82c2373", size = 17243006, upload-time = "2026-10-28T19:22:39.425Z" }, + { url = "https://files.pythonhosted.org/packages/de/1c/ff84a6133a6e89c390bff6b25d9bad6598e7891b205f4b30b496906d636d/mypy-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a5b5da107dc2c93fb0a22f7a45dbaea5dc131afc7056b5468d78e819f9a138e6", size = 17557728, upload-time = "2026-10-28T19:21:43.607Z" }, + { url = "https://files.pythonhosted.org/packages/15/4a/effa47d39b8fadf679c08e7c78559e523f9769a42a75662d2d15b2b9cdd6/mypy-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c3a4d0c47053cc90035dd3a2c321112b593ad42935d0cbfd747288e1564fa8ca", size = 12370826, upload-time = "2026-10-28T19:22:29.779Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/b214ececb1a97fbb61ab3dfd48ae3cffd92ae2c148af4c9dc94c6c56772b/mypy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0c5bf267645351887b72903fd2f5fbddcf85234e0e6c4ad94e2fcfb4f0c3f971", size = 11851083, upload-time = "2026-10-28T19:23:11.288Z" }, + { url = "https://files.pythonhosted.org/packages/57/5b/e44a2edb0b25a9f1429de770e1d0238af23302671dd40c77ac5efeb0c559/mypy-2.4.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:db599d03e7d60d15f2e0a11665c18bc52a88699fcab990fdf8cecd5be8ac801c", size = 14243144, upload-time = "2026-10-28T19:21:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/59/d4/5c99dc3a28e11d2f0fcbb02df63d2d3e574dedc0f9f69d50901df305a7a6/mypy-2.4.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6c7210e40dd423af7d65cbe428b5906d11dc60659337bdae4e5fdf02ce32ed7", size = 14409316, upload-time = "2026-10-28T19:22:15.515Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c9/3108b38f4c30eabd80194f5d9771a87e27196c5e0ddb5bf4b99c6ed1e97e/mypy-2.4.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2d4a51db92059eecc2c33b2a5194b28585ebfd917292c015fe87a77dbff6cd2", size = 15445573, upload-time = "2026-10-28T19:22:05.635Z" }, + { url = "https://files.pythonhosted.org/packages/dd/12/84a53eceaf11c46dfd7931fb6b8902d01fb33787d3a991e81c461458150b/mypy-2.4.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:01b7718552d017f88d1151f3cc9d6556d778a90bd58060a2c3d87d070f9099fb", size = 15732989, upload-time = "2026-10-28T19:23:06.512Z" }, + { url = "https://files.pythonhosted.org/packages/65/ba/bf017dc2abdf83b2f74e29c43834b7af67dc1f1b20202f02dae60b4781a0/mypy-2.4.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:3467b1d13000812ab85dc2a3834b9379ec77798ca1bcabe434e23fb21fead692", size = 7898932, upload-time = "2026-10-28T19:21:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/76/05/0c22b1f0390120a8592d31209cb6b9f932b6a0b426fe643e550de15139e3/mypy-2.4.1-cp315-cp315-win_amd64.whl", hash = "sha256:080cf64a60b90a73435542feecf4171904cc5710658165b40c6869115d4aa100", size = 11577577, upload-time = "2026-10-28T19:22:01.302Z" }, + { url = "https://files.pythonhosted.org/packages/73/fb/ac01e173629697ddac2f11e51efd10347d673f9a7311b03f4aba710ebccd/mypy-2.4.1-cp315-cp315-win_arm64.whl", hash = "sha256:f9118a3f50de264b614861bb11814833a742138ac3c8e2e49a860b6977fb2704", size = 11173540, upload-time = "2026-10-28T19:21:46.444Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e4/b9b9c8eb09e70927ee65b1456cfed35710b6e4ff0b07f364ceaf2202307c/mypy-2.4.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:9d7dafd56179d296651df9e1844b91c9517c379398f017a2a435d077a28943c1", size = 15706623, upload-time = "2026-10-28T19:22:20.528Z" }, + { url = "https://files.pythonhosted.org/packages/26/74/1ceb1ca884add0565e618d013dd24333039100dc0fcc42661561f2e1cae5/mypy-2.4.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d923cec43316f0e6736b8298fd00abeaaf348a091c20e7296b3aebceecb63eca", size = 15944095, upload-time = "2026-10-28T19:21:04.779Z" }, + { url = "https://files.pythonhosted.org/packages/2b/68/27bcf9b4efa22733b6050485ae37845b94c9b8851abe4fc60632d905c32d/mypy-2.4.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6e081b466e88d4343adffe11aff0f02df34b5b9070b4b700053a0b7294c0be1", size = 17223065, upload-time = "2026-10-28T19:23:03.724Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a3/ed5724b317bf585c29f3c8be2792e97d45925d65a9c8af40ceeb267072e6/mypy-2.4.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:70196470ac300f444a302a738541101893c2c57804078b33690286a7899149e9", size = 17560589, upload-time = "2026-10-28T19:21:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/42/2f/005185e6ab4752fd6cc2d1a6d1421fe21979770afe2490c00c555f34a913/mypy-2.4.1-cp315-cp315t-win_amd64.whl", hash = "sha256:de95648e0a6d46fa9929b956ed2c87c5dc9933d4d59945e6390e9d753c74e7c1", size = 12353324, upload-time = "2026-10-28T19:22:51.481Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d6/20420cc9c780087139662534d128c2f3274361f8bb7e9cf0d5495a846ed5/mypy-2.4.1-cp315-cp315t-win_arm64.whl", hash = "sha256:ef8d5be90032261197ac388d945d8ff4f476c95880798a1911760af64ddfe71f", size = 11845599, upload-time = "2026-10-28T19:22:23.136Z" }, + { url = "https://files.pythonhosted.org/packages/21/5c/6e8010f8f2c4ad817364592d4b3883633417269b913fc9d5174fdeb7e565/mypy-2.4.1-py3-none-any.whl", hash = "sha256:1784648d6ec8c2b5465e3f26241b845a0b3cb4e56c993c64abe16989e7698999", size = 2801286, upload-time = "2026-10-28T19:21:19.516Z" }, +] + +[[package]] +name = "mypy-boto3-batch" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/2c/40282c3be151aeb82c7fa84e75902634ac2e22c2173cf1157812f8faa808/mypy_boto3_batch-1.44.0.tar.gz", hash = "sha256:9f2f34e91b8fe1d8b630756d24b5179ee2e2f34e22458cea15fe0dd118ea28f3", size = 42433, upload-time = "2026-11-10T21:49:36.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/72/3bb3a3ea84d5715f4830203bdd91c23531de1b19527bf3000051413c543f/mypy_boto3_batch-1.44.0-py3-none-any.whl", hash = "sha256:6aa682182cee610b70f446f173d90b1875c94705463431a728891cc0397b6830", size = 47578, upload-time = "2026-11-10T21:49:34.954Z" }, +] + +[[package]] +name = "mypy-boto3-cloudformation" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/a5/93be64dc9903dc5a51f634485a21ff9ff203cc9e76e9b0b4786dd5d188b6/mypy_boto3_cloudformation-1.44.0.tar.gz", hash = "sha256:212378546655a54a1adc11d285aefd8aacd237f5b8568e7ad270172d7dfe47ef", size = 61605, upload-time = "2026-11-10T21:50:21.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/45/80111e1649f84f003addb195712ab1f395b3becf141c43f19445aa2309e4/mypy_boto3_cloudformation-1.44.0-py3-none-any.whl", hash = "sha256:f090c02ffc6ac4cf6f4b0659e8402ef2de91ff1cd281b9d9a4c610d150a05624", size = 72592, upload-time = "2026-11-10T21:50:19.141Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/6f/43edf3be2748d0e40974a5ee046a8bb4c9961a4bdd3e7d5c6dcbf32d1af6/mypy_extensions-1.2.0.tar.gz", hash = "sha256:c6e275a07f1c2fc096b62940452d0eaa3f3af7de45d704787866240a7c6d5a51", size = 6566, upload-time = "2026-11-03T16:26:33.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/99/7b1adac7e97ce7bbda34792dc6e9b2f5cc5cffd1875883f78a818b2a2687/mypy_extensions-1.2.0-py3-none-any.whl", hash = "sha256:993e7a20aa5671eb41a7132cf555af30062da99729601601b9a8382b11cae768", size = 5114, upload-time = "2026-11-03T16:26:32.731Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "ruff" +version = "0.17.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/3d154514b5bad104f7e1c13169c2d09aa9959fccdc06eb47610ccdfa83ed/ruff-0.17.4.tar.gz", hash = "sha256:f32d178b50e826e00f6c6c1be3f56333068acb9b67fe167143527fa9b81e890d", size = 5357750, upload-time = "2026-11-05T18:52:17.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/61/c83c78bbb6108f6c6270a106cf12b54175f4f42da088b9bb880f18e31289/ruff-0.17.4-py3-none-linux_armv6l.whl", hash = "sha256:2b08c4ff98dc234f4a3871a3105353b922fa875ea1073ca31001eb8c06bb4d89", size = 10252195, upload-time = "2026-11-05T18:51:36.594Z" }, + { url = "https://files.pythonhosted.org/packages/0a/12/dd158708378866e8b53e145a465dbc51a7cccb9fa197a74d67eaedde7234/ruff-0.17.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:13e781c3c5f663e7fba737eaaeb70dd943dd55171daf316afb25cd5268cfe0c7", size = 10266044, upload-time = "2026-11-05T18:51:39.502Z" }, + { url = "https://files.pythonhosted.org/packages/28/32/89a94a8eb11fd505a381ae6f04e190d038d9958695209dc1786ce5cfcda3/ruff-0.17.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ed2f416349944f908f9574215994602242b0836844eba48eec61473033afca0", size = 9432227, upload-time = "2026-11-05T18:51:41.968Z" }, + { url = "https://files.pythonhosted.org/packages/58/de/a392dc38b215798ae72e639d228b7f6c02d826a95d1c0ad7c4c0ee49ef5b/ruff-0.17.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ea6e0017c785f7282092b11e46338e4ebed1b9e575ebf2e55a8a1eda968241a", size = 10136900, upload-time = "2026-11-05T18:51:44.562Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8f/a880f60f42005f6ffdf58f77af34c7ee327000b5607f48a2f6a69d8b242c/ruff-0.17.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7e061dcc0933c7031a616ac8d66ac540309b2aa21ab558101a8f53e9ac7a1185", size = 10051245, upload-time = "2026-11-05T18:51:46.7Z" }, + { url = "https://files.pythonhosted.org/packages/09/55/98844264108af7d5ec27ca3a9cd5a989094ca657a14599c4b69a537a0e9a/ruff-0.17.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f99e95a1515161138be3cbd1b06c06e739d8feb38ade35564556999fffc3686e", size = 10704341, upload-time = "2026-11-05T18:51:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/28/a2/5edf97659c3ac7c73f978003bd314936f671037fdac6b978e9de3e4b2b65/ruff-0.17.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f28cf0dd37cb172fa5a8d2e92e6ada3a9dbd0609f094ed24039929eef0b7524", size = 11394465, upload-time = "2026-11-05T18:51:51.534Z" }, + { url = "https://files.pythonhosted.org/packages/f2/8f/51371ae856b2bef4d15a044a574695fbc0f6ae63aafa42a2a22428070fb6/ruff-0.17.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b21c771b296888e8a35c6f48555f4b44e4483f61675d6ed51810a5203469ce6", size = 10988262, upload-time = "2026-11-05T18:51:53.91Z" }, + { url = "https://files.pythonhosted.org/packages/af/1b/62874f14c7873f27c7887d2b1ea41de990e14ba9518801ffe8eacb5d51a6/ruff-0.17.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2cd025509b2e15721b426fc70fd946d71f43b3db3b08e946dfec2d696d911690", size = 10572829, upload-time = "2026-11-05T18:51:56.129Z" }, + { url = "https://files.pythonhosted.org/packages/98/30/860b9dae67432b16b88558a9cb2d3194825fa62838f8b9cc49f5d9022095/ruff-0.17.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:49b77f7b3921669466882a4881168801bdb06ec2cd83fb936049016cf5fdece1", size = 10617545, upload-time = "2026-11-05T18:51:58.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/08/781a4b41a1c83fd6e30fea610d6502a6f94e49cf94693fd37bdcbc6bfe11/ruff-0.17.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e3b84eb74f260d1c2dbd236fd9d4c011d4fdd0a52bdf8aea57a10e3a1b71fca5", size = 10111955, upload-time = "2026-11-05T18:52:00.59Z" }, + { url = "https://files.pythonhosted.org/packages/3c/e9/94b1c7b677120f30839254518924f73a8157e9bcfa200edcd5796fecdb99/ruff-0.17.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:20e43d7a08e712e33067ba887e6574546c79bd4831596ce8f16803ac7ff9ad90", size = 10058355, upload-time = "2026-11-05T18:52:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/27/5f/a825910b9f309336dc33cb8c0774126662c1d2f765699b3e0a1bd538d01f/ruff-0.17.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0d1f4848fd2ec0499be4046db9b7dfcc952666885f00233cc7033515282556a8", size = 10415932, upload-time = "2026-11-05T18:52:06.264Z" }, + { url = "https://files.pythonhosted.org/packages/8b/b8/0796468423413ea4df1a6e2442ae26cf49b8f7dda0eecf246e0d5d0dde54/ruff-0.17.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6007e7f3a0e55b0fbdb947af69cf24c21b4928df6be8ef3e767c69b5ccccf496", size = 10826033, upload-time = "2026-11-05T18:52:08.675Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ab/d8e407669ffe5014429baf780c2df47e001968a6e4c17f615e2e7b13a9ba/ruff-0.17.4-py3-none-win32.whl", hash = "sha256:3971a78cba76298b284c8321e6d40d93045ed348bec5c369d85099efc97fb7cf", size = 9931993, upload-time = "2026-11-05T18:52:11.169Z" }, + { url = "https://files.pythonhosted.org/packages/bd/15/42af8a72d8946e3c9efd2bbf6641e462b6832783b0d043d63ef5b280ba7f/ruff-0.17.4-py3-none-win_amd64.whl", hash = "sha256:67c5a894fc054cf41f75f7afa24815de5a74329d97634126c2a1015740ff4e8c", size = 10292922, upload-time = "2026-11-05T18:52:13.436Z" }, + { url = "https://files.pythonhosted.org/packages/68/9e/f1dbffcfb8ff25dadbc21f87a8b73c3848abe4abada9d4b08ccef8bec40c/ruff-0.17.4-py3-none-win_arm64.whl", hash = "sha256:181ae38a9a8fb94925bf7d0794327b48d7fd7f4b50cfd84c0e999cc09ef8d131", size = 9889396, upload-time = "2026-11-05T18:52:15.549Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.19.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/c5/0a5ce655109cb96b8dfad36908a4e108d15f5dc2eff8fbc3146c4312e704/s3transfer-0.19.3.tar.gz", hash = "sha256:8326acc3d056d7f7b8a37110c7d2aea41f67e83f6ae79cd3b5fbcca34c726406", size = 166087, upload-time = "2026-10-19T19:19:49.751Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/a4/88f9e10498759121060b8050a1554753dfc04df39bbbcc7aad9ecc8d9440/s3transfer-0.19.3-py3-none-any.whl", hash = "sha256:24eb7535804306355e31c952a90022bf5b2f9d760f9a11062c7493a217c08553", size = 90252, upload-time = "2026-10-19T19:19:48.309Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "types-s3transfer" +version = "0.19.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/b2/4f2d72b5d234c81caa07dee688cacee2c8dc5bd793e9ae70a573f00d6425/types_s3transfer-0.19.3.tar.gz", hash = "sha256:99422ef028a5f854cd92d1e72cf4208bd0569f68a87936ca02fb9f91a68cdcd3", size = 13780, upload-time = "2026-10-20T02:24:19.016Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/98/972fa03c9a6e1cc9184a28ea0354af50a6764bb172fe2d0751798a295f07/types_s3transfer-0.19.3-py3-none-any.whl", hash = "sha256:6843143f3b79e3cb73e58b6f45738e28e88d9f681fd32cc5ef7fa16f0c193356", size = 19476, upload-time = "2026-10-20T02:24:17.682Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] From 796e3ac021b1882cf8a5fa64d3f0ddb1dafe9dc1 Mon Sep 17 00:00:00 2001 From: Chris Holden Date: Wed, 11 Nov 2026 15:13:11 -0500 Subject: [PATCH 2/4] test: add hls-submit to tests --- .github/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a898ce3e..4994a7f5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,6 +36,9 @@ jobs: - *shared - "src/hls-workflows/**" - "src/hls-utilities/**" + hls-submit: + - *shared + - "src/hls-submit/**" - name: List changed packages id: packages From 252d288d07c3a57a4f350fd723c434cdc16308a1 Mon Sep 17 00:00:00 2001 From: Chris Holden Date: Wed, 11 Nov 2026 15:13:31 -0500 Subject: [PATCH 3/4] chore: wire hls-submit into pixi env --- pixi.lock | 36 ++++++++++++++++++++++++++++++++++++ pixi.toml | 2 ++ 2 files changed, 38 insertions(+) diff --git a/pixi.lock b/pixi.lock index 9a593a3f..20c6c396 100644 --- a/pixi.lock +++ b/pixi.lock @@ -1057,6 +1057,7 @@ environments: - conda: s3://hls-conda-channels/stable/linux-64/hls-libs-v3.5.1.2-hb0f4dca_0.conda - conda_source: fmask[078768b5] @ git+https://github.com/nasa-impact/fmask-v5?tag=v5.0.1.post3#9aacc7eec3d9733c706f169a0de3979fd640a37d - conda_source: lasrc-rs[eb31d84c] @ git+https://github.com/nasa-impact/lasrc-rs?subdirectory=conda&rev=2c5a859d71bdb8d376281b379435ed99e8e3206e#2c5a859d71bdb8d376281b379435ed99e8e3206e + - pypi: ./src/hls-submit - pypi: ./src/hls-utilities - pypi: ./src/hls-workflows - pypi: direct+https://github.com/NASA-IMPACT/hls-vi/releases/download/v1.20.2/hls_vi-1.20.2-py3-none-any.whl @@ -1067,7 +1068,9 @@ environments: - pypi: git+https://github.com/NASA-IMPACT/hls-metadata?tag=v2.9#001441ca78cb54cdbd56a16b3ebcb75db6f67103 - pypi: git+https://github.com/NASA-IMPACT/hls-thumbnails?tag=v1.3#bbe6371a687cbcf52524770275a0d39ee7f5338f - pypi: git+https://github.com/USGS-EROS/espa-python-library.git?rev=v2.0.0#476a1c9d9ab52dea13629e8f236fd6604125282b + - pypi: https://files.pythonhosted.org/packages/02/72/3bb3a3ea84d5715f4830203bdd91c23531de1b19527bf3000051413c543f/mypy_boto3_batch-1.44.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/08/45/80111e1649f84f003addb195712ab1f395b3becf141c43f19445aa2309e4/mypy_boto3_cloudformation-1.44.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/09/40/9d521973cae7f7ef8b1f0d0e28a3db0f851c1f1dca45d4c2ed5360bb7246/dicttoxml-1.7.16-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0e/ae/6546865a7e96d3920d4e6cec3e8ff88229a38737efe9dfa558981c57c609/mypy_boto3_s3-1.44.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl @@ -1413,6 +1416,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - conda_source: fmask[f3bd3c75] @ git+https://github.com/nasa-impact/fmask-v5?tag=v5.0.1.post3#9aacc7eec3d9733c706f169a0de3979fd640a37d - conda_source: lasrc-rs[688f3e7c] @ git+https://github.com/nasa-impact/lasrc-rs?subdirectory=conda&rev=2c5a859d71bdb8d376281b379435ed99e8e3206e#2c5a859d71bdb8d376281b379435ed99e8e3206e + - pypi: ./src/hls-submit - pypi: ./src/hls-utilities - pypi: ./src/hls-workflows - pypi: direct+https://github.com/NASA-IMPACT/hls-vi/releases/download/v1.20.2/hls_vi-1.20.2-py3-none-any.whl @@ -1423,7 +1427,9 @@ environments: - pypi: git+https://github.com/NASA-IMPACT/hls-metadata?tag=v2.9#001441ca78cb54cdbd56a16b3ebcb75db6f67103 - pypi: git+https://github.com/NASA-IMPACT/hls-thumbnails?tag=v1.3#bbe6371a687cbcf52524770275a0d39ee7f5338f - pypi: git+https://github.com/USGS-EROS/espa-python-library.git?rev=v2.0.0#476a1c9d9ab52dea13629e8f236fd6604125282b + - pypi: https://files.pythonhosted.org/packages/02/72/3bb3a3ea84d5715f4830203bdd91c23531de1b19527bf3000051413c543f/mypy_boto3_batch-1.44.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/08/45/80111e1649f84f003addb195712ab1f395b3becf141c43f19445aa2309e4/mypy_boto3_cloudformation-1.44.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/09/40/9d521973cae7f7ef8b1f0d0e28a3db0f851c1f1dca45d4c2ed5360bb7246/dicttoxml-1.7.16-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0e/ae/6546865a7e96d3920d4e6cec3e8ff88229a38737efe9dfa558981c57c609/mypy_boto3_s3-1.44.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl @@ -2109,11 +2115,14 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.5.0-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026e-h5d301ff_0.conda + - pypi: ./src/hls-submit - pypi: ./src/hls-utilities - pypi: ./src/hls-workflows - pypi: git+https://github.com/USGS-EROS/espa-python-library.git?rev=v2.0.0#476a1c9d9ab52dea13629e8f236fd6604125282b + - pypi: https://files.pythonhosted.org/packages/02/72/3bb3a3ea84d5715f4830203bdd91c23531de1b19527bf3000051413c543f/mypy_boto3_batch-1.44.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz + - pypi: https://files.pythonhosted.org/packages/08/45/80111e1649f84f003addb195712ab1f395b3becf141c43f19445aa2309e4/mypy_boto3_cloudformation-1.44.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0e/ae/6546865a7e96d3920d4e6cec3e8ff88229a38737efe9dfa558981c57c609/mypy_boto3_s3-1.44.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1f/c9/e0f8e4e6e8a69e5959b06499582dca6349db6769cc7fdfb8a02a7c75a9ae/lxml_stubs-0.5.1-py3-none-any.whl @@ -2183,10 +2192,13 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ruff-0.17.4-ha19a7bb_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda + - pypi: ./src/hls-submit - pypi: ./src/hls-utilities - pypi: ./src/hls-workflows - pypi: git+https://github.com/USGS-EROS/espa-python-library.git?rev=v2.0.0#476a1c9d9ab52dea13629e8f236fd6604125282b + - pypi: https://files.pythonhosted.org/packages/02/72/3bb3a3ea84d5715f4830203bdd91c23531de1b19527bf3000051413c543f/mypy_boto3_batch-1.44.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz + - pypi: https://files.pythonhosted.org/packages/08/45/80111e1649f84f003addb195712ab1f395b3becf141c43f19445aa2309e4/mypy_boto3_cloudformation-1.44.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/0e/ae/6546865a7e96d3920d4e6cec3e8ff88229a38737efe9dfa558981c57c609/mypy_boto3_s3-1.44.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl @@ -13612,6 +13624,16 @@ packages: - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-9_cp313.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.5.0-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026e-h5d301ff_0.conda +- pypi: ./src/hls-submit + name: hls-submit + requires_dist: + - boto3>=1.42.34 + - click>=8.1 + - boto3-stubs[batch,cloudformation]>=1.36.0 ; extra == 'dev' + - mypy>=1.14.0 ; extra == 'dev' + - pytest>=9.0.2 ; extra == 'dev' + - ruff>=0.9.0 ; extra == 'dev' + requires_python: '>=3.13' - pypi: ./src/hls-utilities name: hls-utilities requires_dist: @@ -13742,6 +13764,13 @@ packages: version: 1.1.0 requires_dist: - lxml>=3.3.3 +- pypi: https://files.pythonhosted.org/packages/02/72/3bb3a3ea84d5715f4830203bdd91c23531de1b19527bf3000051413c543f/mypy_boto3_batch-1.44.0-py3-none-any.whl + name: mypy-boto3-batch + version: 1.44.0 + sha256: 6aa682182cee610b70f446f173d90b1875c94705463431a728891cc0397b6830 + requires_dist: + - typing-extensions ; python_full_version < '3.12' + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl name: cryptography version: 50.0.2 @@ -13761,6 +13790,13 @@ packages: version: 0.7.1 sha256: a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61 requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*' +- pypi: https://files.pythonhosted.org/packages/08/45/80111e1649f84f003addb195712ab1f395b3becf141c43f19445aa2309e4/mypy_boto3_cloudformation-1.44.0-py3-none-any.whl + name: mypy-boto3-cloudformation + version: 1.44.0 + sha256: f090c02ffc6ac4cf6f4b0659e8402ef2de91ff1cd281b9d9a4c610d150a05624 + requires_dist: + - typing-extensions ; python_full_version < '3.12' + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/09/40/9d521973cae7f7ef8b1f0d0e28a3db0f851c1f1dca45d4c2ed5360bb7246/dicttoxml-1.7.16-py3-none-any.whl name: dicttoxml version: 1.7.16 diff --git a/pixi.toml b/pixi.toml index 767f0b28..0765ddcf 100644 --- a/pixi.toml +++ b/pixi.toml @@ -64,6 +64,8 @@ py-spy = "*" [feature.dev.pypi-dependencies] hls-utilities = { path = "./src/hls-utilities", editable = true, extras = ["dev"] } hls-workflows = { path = "./src/hls-workflows", editable = true, extras = ["dev"] } +# Operator tooling for submitting experiment jobs; not installed in the image. +hls-submit = { path = "./src/hls-submit", editable = true, extras = ["dev"] } # Type stubs for boto3 clients (e.g. CloudWatchLogsClient in scripts/plot_metrics.py) boto3-stubs = { version = ">=1", extras = ["logs"] } From 43e592b9eb6aea4f1647d86b4a5c0611361e2c2d Mon Sep 17 00:00:00 2001 From: Chris Holden Date: Wed, 11 Nov 2026 15:21:18 -0500 Subject: [PATCH 4/4] fix: centralize thread count defn --- src/hls-submit/src/hls_submit/common.py | 4 ++++ src/hls-submit/src/hls_submit/lasrc.py | 3 --- src/hls-submit/tests/test_cli.py | 1 + src/hls-submit/tests/test_common.py | 1 + 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/hls-submit/src/hls_submit/common.py b/src/hls-submit/src/hls_submit/common.py index 0a94a9e7..acc86cbf 100644 --- a/src/hls-submit/src/hls_submit/common.py +++ b/src/hls-submit/src/hls_submit/common.py @@ -229,7 +229,11 @@ def base_environment(self, default_s30_input_prefix: str = "") -> dict[str, str] "DEBUG_BUCKET": self.debug_bucket or self.output_bucket, # Required by the Sentinel environment config, unused by experiments. "GIBS_OUTPUT_BUCKET": "foo", + # Set even though the job definition has defaults, so the thread + # count is part of the submitted experiment. OpenMP (C LaSRC, numpy + # BLAS) and rayon (Rust LaSRC) each read their own variable. "OMP_NUM_THREADS": str(self.threads), + "RAYON_NUM_THREADS": str(self.threads), } if self.metric_log_group_name: env["METRIC_LOG_GROUP_NAME"] = self.metric_log_group_name diff --git a/src/hls-submit/src/hls_submit/lasrc.py b/src/hls-submit/src/hls_submit/lasrc.py index 639ea0bc..fda0a39a 100644 --- a/src/hls-submit/src/hls_submit/lasrc.py +++ b/src/hls-submit/src/hls_submit/lasrc.py @@ -24,9 +24,6 @@ def build_jobs(options: SubmitOptions, impls: tuple[str, ...]) -> list[JobSpec]: **options.base_environment(DEFAULT_S30_INPUT_PREFIX), "VIIRS_AUX_STARTING_DATE": "20210101", "LASRC_AUX_DIR": "/var/lasrc_aux", - # OMP_NUM_THREADS caps the C OpenMP loops; the Rust rayon pool reads - # RAYON_NUM_THREADS instead. - "RAYON_NUM_THREADS": str(options.threads), } return [ JobSpec( diff --git a/src/hls-submit/tests/test_cli.py b/src/hls-submit/tests/test_cli.py index 34a83f0a..7c950bd3 100644 --- a/src/hls-submit/tests/test_cli.py +++ b/src/hls-submit/tests/test_cli.py @@ -66,6 +66,7 @@ def test_fmask_submit_requester_pays( "DEBUG_BUCKET": "hls-science-container-testing", "GIBS_OUTPUT_BUCKET": "foo", "OMP_NUM_THREADS": "2", + "RAYON_NUM_THREADS": "2", "METRIC_LOG_GROUP_NAME": f"/hls-orchestration/{stack['name']}/log-metrics", "INPUT_PREFIX": "datasets/belmanip2/S30/", "INPUT_REQUESTER_PAYS": "true", diff --git a/src/hls-submit/tests/test_common.py b/src/hls-submit/tests/test_common.py index e2a90366..0e5ca7c2 100644 --- a/src/hls-submit/tests/test_common.py +++ b/src/hls-submit/tests/test_common.py @@ -128,6 +128,7 @@ def test_base_environment_overrides(granules_csv: Path) -> None: assert env["DEBUG_BUCKET"] == "dbg" assert env["METRIC_LOG_GROUP_NAME"] == "/custom" assert env["OMP_NUM_THREADS"] == "4" + assert env["RAYON_NUM_THREADS"] == "4" assert env["HLS_EXPERIMENT_FMASK_BUILD"] == "baseline" assert env["HLS_EXPERIMENT_RUN"] == "2"