diff --git a/.coveragerc-satellite b/.coveragerc-satellite new file mode 100644 index 0000000..a4cdd3d --- /dev/null +++ b/.coveragerc-satellite @@ -0,0 +1,36 @@ +# Phase 25 satellite coverage lane (P2 verification-completeness fix). +# +# The default [tool.coverage.run] in pyproject.toml sets ``branch = true``, +# which forces coverage's C tracer. Any subprocess that imports numpy/pandas +# under the C tracer raises ``numpy: cannot load module more than once per +# process``, so the four heaviest new satellite modules — _goes_extract, +# _goes_s3, _internal/merge/satellite, core/schemas/satellite — could not be +# coverage-measured at all (the >=80% gate was inferred, not proven). +# +# This config measures LINE coverage only (``branch = false``), which lets +# coverage use the sys.monitoring (sysmon) backend instead of the C tracer. +# sysmon does not trigger the numpy single-load reload error, so the four +# modules become measurable. Line coverage is exactly what the CLAUDE.md +# ">=80% coverage on new code" gate requires. +# +# Drive it with ``COVERAGE_CORE=sysmon`` (see the ``satellite-coverage`` CI +# lane in .github/workflows/test.yml). Run it as its OWN pytest process so the +# satellite test modules import numpy/pandas exactly once. +[run] +branch = false +# Path-based include (not dotted module ``source``): the four target modules +# are imported lazily inside test functions, and under the sysmon backend the +# dotted-name ``source`` filter does not reliably attach to a module that was +# imported after coverage start. Filesystem ``include`` globs match on the +# resolved file path instead, which is stable regardless of import timing. +include = + */mostlyright/weather/_fetchers/_goes_extract.py + */mostlyright/weather/_fetchers/_goes_s3.py + */mostlyright/_internal/merge/satellite.py + */mostlyright/core/schemas/satellite.py + +[report] +show_missing = true +skip_covered = false +# 80% line floor on the four otherwise-unmeasurable satellite modules. +fail_under = 80 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c89e09a..99453c8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -236,3 +236,40 @@ jobs: --cov-fail-under=85 \ --cov-report=term-missing:skip-covered \ -q + + # Phase 25 (P2 verification-completeness fix): the default coverage-gate job + # above runs ``--cov-branch``, which forces coverage's C tracer. Any + # subprocess importing numpy/pandas under the C tracer raises + # ``numpy: cannot load module more than once per process``, so the four + # heaviest new satellite modules (_goes_extract, _goes_s3, + # _internal/merge/satellite, core/schemas/satellite) could not be + # coverage-measured — the >=80% gate on them was inferred, not proven. This + # dedicated lane measures LINE coverage under the sys.monitoring (sysmon) + # backend (branch=false in .coveragerc-satellite), which does NOT trip the + # numpy reload, and fails the build if any of the four drops below 80%. + satellite-coverage: + needs: changes + runs-on: ubuntu-latest + steps: + - name: No-op (no Python-relevant changes) + if: needs.changes.outputs.py != 'true' + run: echo "No Python-relevant changes in this PR; satellite-coverage is a no-op success." + + - uses: actions/checkout@v4 + if: needs.changes.outputs.py == 'true' + + - name: Install uv + if: needs.changes.outputs.py == 'true' + uses: astral-sh/setup-uv@v3 + + - name: Set up Python + if: needs.changes.outputs.py == 'true' + run: uv python install 3.12 + + - name: Sync workspace + satellite extra + if: needs.changes.outputs.py == 'true' + run: uv sync --all-packages --extra satellite + + - name: Satellite coverage (4 modules, >= 80% line via sysmon) + if: needs.changes.outputs.py == 'true' + run: bash scripts/satellite_coverage.sh diff --git a/README.md b/README.md index 8ee4c97..405657a 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,33 @@ const result = validateRows(rows, "schema.observation.v1"); // — ready to pass through to an agent's tool-call response. ``` +## GOES satellite (Phase 25) + +GOES-16/19 ABI L2 single-pixel extraction from NOAA's anonymous public NODD +buckets — a leakage-safe **feature supplement** (cloud-mask / land-surface +covariates), not a primary Tmax/Tmin settlement source. Ships as the optional +`mostlyrightmd-weather[satellite]` extra (whole-file S3/GCS reads via +`s3fs`/`gcsfs` + `h5netcdf`; no hosted backend — reads the same anonymous public +buckets as the AWC/IEM/NWP calls). + +```bash +pip install mostlyrightmd-weather[satellite] +``` + +```python +from mostlyright.weather.satellite import satellite +df = satellite("KNYC", "goes16", product="ABI-L2-ACMC", start=..., end=...) +``` + +The fleet bulk/training path is `python -m mostlyright.weather.satellite +backfill` (per-`(satellite,year,month)` slices, crash-safe resume, +`--mirror aws|gcp`, Thread/Process split). `max_workers` + the S3 rate cap are +probe-derived constants — run `python -m mostlyright.weather.satellite probe` to +re-measure. See [docs/satellite.md](docs/satellite.md) for cheap-CONUS steering, +DSRF gating, the 28 TB / near-data reality, and the deferred-paid-adapter note +(the future paid adapter shares the `noaa_goes` source identity — byte-identical +— distinguished only by the informational `delivery` lineage column). + ## Why mostlyright - **No hosted backend.** Direct calls to public APIs (NOAA, NWS, IEM, Kalshi, Polymarket). No proxy. No vendor account. No rate-limited tier. diff --git a/docs/satellite.md b/docs/satellite.md new file mode 100644 index 0000000..a38d0ff --- /dev/null +++ b/docs/satellite.md @@ -0,0 +1,176 @@ +# GOES Satellite (Phase 25) + +GOES-16/19 ABI Level-2 **single-pixel** extraction from NOAA's anonymous public +NODD buckets — a leakage-safe **feature supplement** for prediction-market +weather research. Ships as the optional `mostlyrightmd-weather[satellite]` extra +(mirrors the `[nwp]` extra and the `forecast_nwp()` pipeline shape). + +> **This is a feature supplement, not a primary signal.** For daily Tmax/Tmin +> settlement (Kalshi NHIGH/NLOW) the NWP forecasts (`forecast_nwp()`) and CLI +> settlements are the load-bearing inputs. Satellite cloud-mask / land-surface +> features are MARGINAL for raw temperature highs/lows — useful as a model +> covariate (cloud cover, clear-sky flags), not as the settlement source. + +## Install + +```bash +pip install mostlyrightmd-weather[satellite] +``` + +The extra brings `boto3` (anonymous UNSIGNED listing), `s3fs` + `gcsfs` +(whole-file reads), `h5netcdf` (HDF5 decode via wheel — no system `libhdf5`), +`xarray`, `numpy`, `pandas`. The module imports cleanly without the extra; the +heavy deps are lazy-imported inside `satellite()` and a missing extra raises a +`SourceUnavailableError` with the install hint. + +## Quick Start + +```python +from datetime import datetime, UTC +from mostlyright.weather.satellite import satellite + +# Cheap CONUS cloud-mask for one station, one day. +df = satellite( + "KNYC", + "goes16", + product="ABI-L2-ACMC", # primary cheap CONUS product + start=datetime(2024, 6, 15, tzinfo=UTC), + end=datetime(2024, 6, 15, 23, 59, tzinfo=UTC), +) +# One row per (station, variable, scan_start); leakage-safe overlay columns +# (source, event_time, knowledge_time, retrieved_at) + qc_status. +``` + +### `--mirror aws|gcp` (transport-only) + +Both `satellite(..., mirror=...)` and the backfill CLI `--mirror` accept +`"aws"` (default) or `"gcp"`. The fleet backfill should run **in-region** on +whichever cloud you use — AWS `us-east-1` or GCS `us-central1` — so the 28 TB of +transient download bandwidth is free (NODD egress). + +`mirror` is a **transport choice only**: the same NOAA GOES product lands in the +same cache partition (`~/.mostlyright/cache/v1/satellite/{satellite}/{product}/{station}/{YYYY}/{MM}.parquet`) +whether fetched from AWS (`noaa-goes16` / `noaa-goes19`) or GCS +(`gcp-public-data-goes-16` / `gcp-public-data-goes-19`). It does **not** change +`df.attrs["source"]` (`"noaa_goes"` for both mirrors) and is **not** a schema +column. + +## Products + +The extractor carries the full registry, but the public default and these docs +steer to **cheap CONUS** products. DSRF (full-disk) is gated — see below. + +| Product | Scale | Notes | +|---|---|---| +| **ABI-L2-ACMC** | CONUS ~0.3–1.5 MB/file | **Primary.** Clear-Sky / Cloud Mask. The cheap default. | +| ABI-L2-LSTC | CONUS ~0.3–1.5 MB/file | Land Surface Temperature. | +| ABI-L2-DSIC / TPWC | CONUS ~0.3–1.5 MB/file | Derived stability / total precipitable water. | +| ABI-L2-DSRF | full-disk ~50 MB/file | **GATED.** Downward Shortwave Radiation, full-disk (~25 of the 28 TB v1 corpus). | + +### DSRF gating + +The live `satellite(..., product="ABI-L2-DSRF")` path emits a one-time warning: +DSRF is full-disk (~50 MB/file) and dominates the v1 corpus. The live fetcher +fetches per-scan and will **never silently start a multi-TB download** — for bulk +DSRF pulls use the backfill CLI and run it **in-region** (near-data compute). + +## QC: annotate-never-drop + +Every row carries `qc_status ∈ {clean, flagged, suspect}` — no row is dropped, no +quarantine file. The severity is deliberately inverted: a physics-violating pixel +is almost always an *extraction* bug, so an error-class finding maps to +`suspect` (kept for inspection) and a warning-class finding maps to `flagged`. A +`pixel_value=None` on a NetCDF `_FillValue` is a clean data condition, not an +error. + +## Leakage safety + +`scan_start_utc` is event-time (parsed from the NetCDF filename, stdlib only); +`as_of_time` / `knowledge_time` is knowledge-time, stamped at fetch (or the +backfill `ingested_at`). Both flow through the SDK's `KnowledgeView` / +`assert_no_leakage`, so a satellite feature backtests the same way it trades — +pass `as_of=` to filter on typed datetimes (never a lexical +string snapshot). + +## Cache + +`~/.mostlyright/cache/v1/satellite/{satellite}/{product}/{station}/{YYYY}/{MM}.parquet`, +filelock-guarded, atomic write, deduped first-seen-wins on +`(station, satellite, product, variable, pressure_level, scan_start)`. The cache +partition is mirror-invariant (D9). + +## Bulk backfill + the 28 TB reality + +The fleet backfill is the bulk/training path: + +```bash +python -m mostlyright.weather.satellite backfill \ + --satellites goes16,goes19 \ + --products ABI-L2-ACMC \ + --stations KNYC \ + --year-start 2024 --year-end 2024 \ + --out ~/.mostlyright/cache/ \ + --max-workers 8 \ + --executor thread \ + --mirror aws \ + --resume +``` + +Per-`(satellite, year, month)` array-job-friendly slices; crash-safe resume +(malformed-key rejection + fsync durability + `.bak` fallback + a single-writer +lockfile); `--executor thread` for small CONUS files, `--executor process` for +DSRF full-disk decode (CPU-bound + GIL-serialized + behind the HDF5 global +mutex). Slices write **directly** to the per-partition cache (no staging, no +intermediate object store). + +**Why whole-file reads, not byte-range / lazy.** The transport reads the ENTIRE +object in one shot — NOT a byte-range / lazy `fs.open` handed to xarray. Single- +pixel byte-range was measured ~4× slower than a full download on a 37 MB DSRF +file (the HDF5 metadata b-tree walk dominates), and the lazy per-range path on +GCS triggers a per-range SSL re-handshake that serializes the pool. So the read +primitive is a single full-object `cat_file` into an in-memory buffer. + +**Scale.** CONUS ~0.3–1.5 MB/file; DSRF full-disk ~50 MB/file; the full v1 corpus +is ≈ 3.67 M files / ~28 TB of transient download → a **tiny** parquet output +(one float per station per scan). ACMC for one station over ~2 years is ≈ 200 GB +download / ~5 h on home internet. Because the download is transient and the +output is tiny, the fleet model is **near-data compute in-region** — run the +backfill on a VM in the same cloud region as the bucket (free egress), keep only +the parquet. + +### Concurrency: `max_workers` + the S3 rate cap are probe-DERIVED + +`max_workers` and the S3 rate-limiter cap are **constants derived from the +satellite rate-limit probe** (mirroring how `forecasts.md`'s +`NOMADS_CONCURRENCY_CAP=4` is documented as empirically derived). They are NOT +guessed and NOT a bare "UNTUNED" caveat: the shipped `_GOES_S3_RATE_HZ` and +`_DEFAULT_MAX_WORKERS` in `satellite/_backfill.py` are floored at the values the +probe records. + +Run the probe to (re-)measure the anonymous-throttle / diminishing-returns knee: + +```bash +python -m mostlyright.weather.satellite probe --mirror aws --out .planning/research +``` + +It measures ListObjectsV2 latency, single-file throughput, and a 1/4/8/16/32 +concurrency sweep, then writes a findings artifact + a satellite section into +`SOURCE-LIMITS.md` — the place the shipped constants cite as provenance. A +provenance-lock test asserts the shipped constants are floored at / match those +recorded values, so the probe RESULT governs the default, not a doc-only note. +Until you run it in-region, the constants stay conservative +(`_GOES_S3_RATE_HZ=20.0`, `_DEFAULT_MAX_WORKERS=8`). + +## Deferred: paid adapter + +This phase ships the **free local tier only**. A future paid adapter +(`strategy="hosted"`) will read a pre-extracted catalog — but it SHARES the +`noaa_goes` source identity (it is byte-identical to live self-extraction) and is +distinguished only by the informational `delivery` lineage column, so a model +trained on adapter data reconciles with live self-extraction (no source drift). + +## See also + +- [`docs/forecasts.md`](forecasts.md) — the NWP forecast path (the load-bearing + Tmax/Tmin signal). +- [`docs/forecast-sources.md`](forecast-sources.md) — forecast source catalog. diff --git a/packages/core/src/mostlyright/_internal/merge/satellite.py b/packages/core/src/mostlyright/_internal/merge/satellite.py new file mode 100644 index 0000000..e3f79cf --- /dev/null +++ b/packages/core/src/mostlyright/_internal/merge/satellite.py @@ -0,0 +1,244 @@ +"""Satellite merge policy — dedup + validate (Phase 25 Wave 2). + +Mirrors the load-bearing ``merge/climate.py`` dedup precedent. Two surfaces: + +``_dedup_satellite_rows`` + First-seen-wins on the full 6-tuple key + ``(station, satellite, product, variable, pressure_level_hpa, + scan_start_utc)`` — a byte-faithful port of the 2i + ``satellite_parquet_2i._dedup_satellite_rows`` (254-274). The full key is + cheap and stays correct under a future repartition even though + ``satellite``/``product``/``station`` are constant within one partition + file. The key does NOT include ``mirror`` (D9): the same NOAA GOES scan + fetched from AWS or GCS is the SAME row and MUST collapse — ``mirror`` is a + transport choice, not part of row identity. + +``_validate_satellite_record`` + Ports the 2i ``_validate_satellite`` (119-221) semantic checks but, per D5 + (annotate-never-drop), returns a STRUCTURED DISPOSITION — a list of + ``(severity, rule, detail)`` findings — instead of an error string that + routed to a sibling quarantine parquet. An empty list means "clean". The + 25-04 orchestrator reduces these findings into ``qc_status`` (worst-rule + wins, severity-inverted). There is NO quarantine path here. + +The registry lookup is a LAZY import of the weather-package ``PRODUCTS`` (the +same deliberate lazy pattern the 2i ``_registry_get`` used to avoid a +core->weather import cycle at module load — ``mostlyrightmd-weather`` depends on +``mostlyright`` core, so the import is resolved only at call time). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Literal + +# Severity vocabulary for findings. The 25-04 qc reducer maps these (with the +# D5 inversion: an "error"-class physics/structure violation becomes +# ``qc_status="suspect"`` because a physics-violating pixel is almost always an +# extraction bug worth keeping, and a "warning" becomes ``"flagged"``). +Severity = Literal["error", "warning"] + + +@dataclass(frozen=True) +class Finding: + """One validation finding feeding the qc_status reducer (D5).""" + + severity: Severity + rule: str + detail: str + + +# --------------------------------------------------------------------------- +# Dedup — first-seen-wins on the 6-tuple (mirror-invariant, D9) +# --------------------------------------------------------------------------- +def _dedup_satellite_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Deduplicate by ``(station, satellite, product, variable, + pressure_level_hpa, scan_start_utc)``. First-seen wins; exact duplicates + collapse. + + Mirrors ``merge_climate``'s first-seen-wins discipline. The key has no + ``mirror`` component (D9): the same scan from either cloud mirror is one + row. Byte-faithful key port of the 2i ``_dedup_satellite_rows`` (254-274), + with the 2i ``station_code`` field renamed to the SDK's ``station`` (ICAO). + """ + seen: dict[tuple[str, str, str, str, float | None, str], dict[str, Any]] = {} + for row in rows: + key = ( + row["station"], + row["satellite"], + row["product"], + row["variable"], + row.get("pressure_level_hpa"), + row["scan_start_utc"], + ) + if key not in seen: + seen[key] = row + return list(seen.values()) + + +# --------------------------------------------------------------------------- +# Validate — structured disposition (annotate-never-drop, D5) +# --------------------------------------------------------------------------- +_PRESSURE_MIN_HPA = 0.1 +_PRESSURE_MAX_HPA = 1100.0 + + +def _registry_get(product: str, variable: str) -> Any: + """Lazy registry lookup (avoids a core->weather import cycle at load). + + Mirrors the 2i ``_registry_get`` lazy-import precedent. Returns the + ``ProductVariable`` for ``(product, variable)`` or ``None`` if unregistered. + """ + from mostlyright.weather._fetchers._goes_extract import PRODUCTS + + return PRODUCTS.get((product, variable)) + + +def _validate_satellite_record(record: dict[str, Any]) -> list[Finding]: + """Validate one satellite row; return a list of findings (empty == clean). + + Ports the 2i ``_validate_satellite`` (119-221) semantic checks but returns + a structured disposition instead of routing to a quarantine parquet (D5): + + - scan-time ordering (M4: ``scan_end == scan_start`` is fine; only + ``scan_end < scan_start`` is a hard reject) + - ``source_object_key`` non-empty (S3/GCS provenance) + - ``(product, variable)`` registered in PRODUCTS + - units contract (``units == registry.units_expected``) + - pressure-level required iff ``is_3d_profile`` + - physics bounds on ``pixel_value`` (``None`` on ``_FillValue`` is clean) + - ``pixel_dqf`` nullability matches ``registry.dqf_good`` + + A ``pixel_value`` of ``None`` (NetCDF ``_FillValue``) is a CLEAN data + condition, not a finding (2i docstring + schema $comment item 12). + """ + findings: list[Finding] = [] + + # Scan-time ordering (M4). + scan_start = record.get("scan_start_utc", "") + scan_end = record.get("scan_end_utc", "") + start_dt = _parse_rfc3339_z(scan_start) + end_dt = _parse_rfc3339_z(scan_end) + if start_dt is None: + findings.append( + Finding("error", "scan_start_format", f"scan_start_utc {scan_start!r} not RFC3339 Z") + ) + if end_dt is None: + findings.append( + Finding("error", "scan_end_format", f"scan_end_utc {scan_end!r} not RFC3339 Z") + ) + if start_dt is not None and end_dt is not None and end_dt < start_dt: + # M4: equal is allowed (continuous full-disk filename loses sub-second + # duration); only end-before-start is a hard reject. + findings.append( + Finding( + "error", + "scan_time_inverted", + f"scan_end_utc {scan_end!r} before scan_start_utc {scan_start!r}", + ) + ) + + # source_object_key non-empty. + source_object_key = record.get("source_object_key", "") + if not source_object_key or not isinstance(source_object_key, str): + findings.append( + Finding("error", "source_object_key_empty", "source_object_key must be non-empty") + ) + + # (product, variable) registered. + product = record.get("product", "") + variable = record.get("variable", "") + pv = _registry_get(product, variable) + if pv is None: + findings.append( + Finding( + "error", + "not_registered", + f"(product, variable) {product}/{variable} not in PRODUCTS registry", + ) + ) + # Without a registry entry the remaining checks cannot run. + return findings + + # Units contract. + units = record.get("units", "") + if units != pv.units_expected: + findings.append( + Finding( + "warning", + "units_contract", + f"units {units!r} != registry units_expected {pv.units_expected!r}", + ) + ) + + # Pressure level required iff is_3d_profile. + pressure = record.get("pressure_level_hpa") + if pv.is_3d_profile: + if pressure is None: + findings.append( + Finding( + "error", + "pressure_required", + f"pressure_level_hpa required for 3D profile {product}/{variable}", + ) + ) + elif not (_PRESSURE_MIN_HPA <= pressure <= _PRESSURE_MAX_HPA): + findings.append( + Finding( + "error", + "pressure_bounds", + f"pressure_level_hpa {pressure} outside " + f"[{_PRESSURE_MIN_HPA}, {_PRESSURE_MAX_HPA}] hPa", + ) + ) + elif pressure is not None: + findings.append( + Finding( + "error", + "pressure_forbidden", + f"pressure_level_hpa must be null for 2D product {product}/{variable}", + ) + ) + + # Physics bounds on pixel_value. None (_FillValue) is a clean data condition. + pixel_value = record.get("pixel_value") + lo, hi = pv.bounds + if pixel_value is not None and not (lo <= pixel_value <= hi): + findings.append( + Finding( + "error", + "physics_bounds", + f"pixel_value {pixel_value} outside registry bounds [{lo}, {hi}]", + ) + ) + + # pixel_dqf nullability matches registry. + pixel_dqf = record.get("pixel_dqf") + if pv.dqf_good is None and pixel_dqf is not None: + findings.append( + Finding( + "warning", + "dqf_nullability", + f"pixel_dqf must be null for {product}/{variable} (no DQF filtering)", + ) + ) + + return findings + + +def _parse_rfc3339_z(ts: object) -> datetime | None: + """Parse a strict ``YYYY-MM-DDTHH:MM:SSZ`` timestamp; return None on miss.""" + if not isinstance(ts, str): + return None + try: + return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ") + except ValueError: + return None + + +__all__ = [ + "Finding", + "_dedup_satellite_rows", + "_validate_satellite_record", +] diff --git a/packages/core/src/mostlyright/core/exceptions.py b/packages/core/src/mostlyright/core/exceptions.py index 977f0be..88cda30 100644 --- a/packages/core/src/mostlyright/core/exceptions.py +++ b/packages/core/src/mostlyright/core/exceptions.py @@ -29,6 +29,8 @@ "DataAvailabilityError", "DataAvailabilityReason", "DeprecatedModelWarning", + "GoesDataCorruptError", + "GoesS3Error", "GribIntegrityError", "HistoricalDepthError", "IssuedAtMissingError", @@ -42,11 +44,15 @@ "NwpModelRetiredError", "OpenMeteoSeamlessLeakageError", "PayloadTooLargeError", + "ProductNotRegisteredError", + "SatelliteError", "SchemaValidationError", "SourceMismatchError", "SourceUnavailableError", + "StationOutOfGridError", "StormNotFoundError", "TemporalDriftError", + "UnitsContractError", # ``MostlyRightMCPError`` is intentionally NOT in ``__all__``. It is # available via module ``__getattr__`` for one release cycle with a # DeprecationWarning; removal target v0.3. @@ -880,6 +886,61 @@ def _payload(self) -> dict[str, Any]: return payload +# ---------------------------------------------------------------------- +# Phase 25: GOES ABI L2 satellite ingest errors +# ---------------------------------------------------------------------- + + +class SatelliteError(MostlyRightError): + """Base class for Phase 25 GOES ABI L2 satellite ingest errors. + + Rehomes the 2i monorepo ``goes_satellite`` typed exceptions into the + SDK's structured hierarchy so the extractor (``_goes_extract.py``) and + the S3 transport (``_goes_s3.py``) raise SDK-native errors instead of + bare ``Exception`` subclasses. Each subclass maps a single failure mode + of the single-pixel extraction path. + """ + + default_error_code = "SATELLITE_ERROR" + + +class GoesS3Error(SatelliteError): + """S3 (or GCS mirror) access failure after retries exhausted.""" + + default_error_code = "GOES_S3_ERROR" + + +class GoesDataCorruptError(SatelliteError): + """NetCDF missing required attributes/variables, or shape unexpected.""" + + default_error_code = "GOES_DATA_CORRUPT" + + +class StationOutOfGridError(SatelliteError): + """Station projection lands outside the product grid.""" + + default_error_code = "STATION_OUT_OF_GRID" + + +class ProductNotRegisteredError(SatelliteError): + """(product, variable) pair not in the PRODUCTS registry.""" + + default_error_code = "PRODUCT_NOT_REGISTERED" + + +class UnitsContractError(GoesDataCorruptError): + """NetCDF variable ``units`` attribute does not match the registry. + + Subclass of :class:`GoesDataCorruptError` (preserving the 2i hierarchy). + Per Phase 25 D5 (annotate-never-drop), the per-variable units mismatch in + ``_extract_from_dataset`` is RECORDED as a ``qc_status="suspect"`` row and + the scan continues — this class stays importable for any genuinely + unrecoverable case, but the per-variable mismatch no longer aborts. + """ + + default_error_code = "UNITS_CONTRACT_ERROR" + + # ---------------------------------------------------------------------- # Deprecation alias: MostlyRightMCPError → MostlyRightError # ---------------------------------------------------------------------- diff --git a/packages/core/src/mostlyright/core/schemas/__init__.py b/packages/core/src/mostlyright/core/schemas/__init__.py index 1545d99..a976d28 100644 --- a/packages/core/src/mostlyright/core/schemas/__init__.py +++ b/packages/core/src/mostlyright/core/schemas/__init__.py @@ -17,6 +17,7 @@ from .observation import ObservationSchema from .observation_ledger import ObservationLedgerSchema from .observation_qc import ObservationQCSchema +from .satellite import SatelliteSchema from .settlement import SettlementSchema # Eager registration — Validator can look up each schema by ID immediately. @@ -33,6 +34,8 @@ register_schema(ObservationQCSchema) # Phase 3.2 addition. register_schema(NwpForecastSchema) +# Phase 25 addition (GOES ABI L2 satellite ingest). +register_schema(SatelliteSchema) #: Public alias for the validator's registry dict, so callers and tests #: can look up schemas by id without reaching into ``core.validator``'s @@ -46,6 +49,7 @@ "ObservationLedgerSchema", "ObservationQCSchema", "ObservationSchema", + "SatelliteSchema", "SettlementSchema", "StationForecastSchema", ] diff --git a/packages/core/src/mostlyright/core/schemas/satellite.py b/packages/core/src/mostlyright/core/schemas/satellite.py new file mode 100644 index 0000000..0591717 --- /dev/null +++ b/packages/core/src/mostlyright/core/schemas/satellite.py @@ -0,0 +1,237 @@ +"""Satellite schema (``schema.satellite.v1``) — Phase 25 (25-01 surface). + +Two surfaces live here: + +``validate_satellite_station`` + The ICAO station-identity validation HOOK the GOES ABI L2 extractor + (``mostlyright.weather._fetchers._goes_extract``) calls at the + ``_build_record`` write-site (Phase 25 D2). ``ColumnSpec`` supports only + dtype/nullable/enum — NO regex (verified ``core/schema.py``) — so the ICAO + ``^[A-Z]{4}$`` contract cannot live as a column pattern; it is enforced via + this hook plus ``validate_icao_for_path`` at the cache-path layer. + +``SatelliteSchema`` + The full ``schema.satellite.v1`` shape contract — the 18 ported 2i fields + plus the SDK overlay columns ``source`` / ``delivery`` / ``qc_status`` / + ``as_of_time``. Registered with the Validator (``core/schemas/__init__.py``) + and wired into the codegen exporter (``scripts/export_schemas.py`` + ``_GROUP_A_SCHEMA_IDS``) so ``schemas/json/schema.satellite.v1.json`` is + emitted deterministically (TS type emission is signature-forward only this + phase — the TS reader is a deferred parity ticket). + +**D2 source identity (USER-LOCKED 2026-06-18).** ``_registered_source`` is the +SINGULAR string ``"noaa_goes"`` — SHARED by live self-extraction AND the future +paid adapter (they are byte-identical by design, so a model trained on adapter +data and inferred on live self-extraction RECONCILES). The per-row ``source`` +overlay column also equals ``"noaa_goes"`` (the validator reconciles both +``df.attrs["source"]`` and the per-row column). ``delivery`` (enum +``{live, hosted}``, default ``"live"``) is INFORMATIONAL LINEAGE ONLY and is NOT +part of source identity. ``mirror`` is a transport choice and is deliberately +NOT a schema column (D9). +""" + +from __future__ import annotations + +import re +from typing import ClassVar + +from ..exceptions import SchemaValidationError +from ..schema import ColumnSpec, Schema + +#: Station identity for satellite rows is the 4-letter ICAO identifier +#: (e.g. ``"KNYC"``), REPLACING the 2i monorepo's 3-letter NWS ``^[A-Z]{3}$`` +#: regex (Phase 25 D2 — USER-LOCKED). Lock the contract to exactly 4 letters; +#: a 3-letter NWS code must fail loudly at record-build time so rows never +#: quarantine en masse downstream. +_ICAO_RE = re.compile(r"^[A-Z]{4}$") + +#: D2 informational lineage enum. ``delivery`` records the delivery channel +#: (free local self-extraction vs the future paid adapter) WITHOUT being part of +#: source identity — both channels share ``_registered_source = "noaa_goes"``. +SATELLITE_DELIVERY_VALUES: tuple[str, ...] = ("live", "hosted") + +#: qc_status values (D5 annotate-never-drop, severity-inverted). ``"clean"`` +#: passed every rule; ``"flagged"`` tripped a warning-class rule; ``"suspect"`` +#: tripped an error-class rule (physics/structure violation — almost always an +#: extraction bug, so the row is KEPT rather than dropped). +SATELLITE_QC_STATUS_VALUES: tuple[str, ...] = ("clean", "flagged", "suspect") + + +def validate_satellite_station(station: str) -> str: + """Validate a satellite row's station identity is a 4-letter ICAO code. + + Returns the station string unchanged when valid (so call sites can use it + inline). Raises :class:`SchemaValidationError` for anything that is not + exactly four uppercase ASCII letters — notably the 2i 3-letter NWS codes, + which must fail loudly rather than silently produce un-joinable rows. + """ + if not isinstance(station, str) or not _ICAO_RE.fullmatch(station): + raise SchemaValidationError( + f"satellite station identity must be a 4-letter ICAO code " + f"(^[A-Z]{{4}}$), got {station!r}", + schema_id="schema.satellite.v1", + ) + return station + + +class SatelliteSchema(Schema): + """``schema.satellite.v1`` — GOES ABI L2 single-pixel scan rows. + + One row per ``(station, satellite, product, variable, pressure_level_hpa, + scan_start_utc)`` — the dedup key (mirror-invariant, D9). Ports the 18 2i + storage fields and adds the SDK overlay columns ``source`` / ``delivery`` / + ``qc_status`` / ``as_of_time``. + """ + + schema_id = "schema.satellite.v1" + + #: D2 source identity — SHARED by live self-extraction AND the future paid + #: adapter, mirror-invariant. SINGULAR (not a frozenset): both delivery + #: channels are byte-identical and reconcile to ONE source. + _registered_source: ClassVar[str] = "noaa_goes" + + COLUMNS: ClassVar[list[ColumnSpec]] = [ + # --- Identity / dedup-key columns (non-nullable) ----------------- + ColumnSpec(name="station", dtype="string", units=None, nullable=False), + ColumnSpec( + name="satellite", + dtype="enum", + units=None, + nullable=False, + enum_values=("goes16", "goes19"), + ), + ColumnSpec(name="product", dtype="string", units=None, nullable=False), + ColumnSpec(name="variable", dtype="string", units=None, nullable=False), + ColumnSpec( + name="pressure_level_hpa", + dtype="float64", + units="hPa", + nullable=True, + notes="null for 2D products; populated (part of the dedup key) for 3D profiles", + ), + ColumnSpec( + name="scan_start_utc", + dtype="timestamp_utc", + units=None, + nullable=False, + notes="UTC scan start from the NetCDF filename _s token (event-time, D4)", + ), + ColumnSpec( + name="scan_end_utc", + dtype="timestamp_utc", + units=None, + nullable=False, + notes="UTC scan end from the NetCDF filename _e token; >= scan_start_utc", + ), + # --- Pixel payload ---------------------------------------------- + ColumnSpec( + name="pixel_value", + dtype="float64", + units=None, + nullable=True, + notes="raw-as-reported single grid cell; null ONLY on the file's _FillValue", + ), + ColumnSpec( + name="pixel_dqf", + dtype="float64", + units=None, + nullable=True, + notes="NOAA DQF integer at the cell; null for variables with no DQF", + ), + ColumnSpec( + name="pixel_row", + dtype="int64", + units=None, + nullable=False, + notes="NetCDF grid row (y) index at the station lat/lon", + ), + ColumnSpec( + name="pixel_col", + dtype="int64", + units=None, + nullable=False, + notes="NetCDF grid col (x) index at the station lat/lon", + ), + ColumnSpec( + name="units", + dtype="string", + units=None, + nullable=False, + notes="physical units from the NetCDF variable units attr", + ), + # --- Provenance -------------------------------------------------- + ColumnSpec( + name="station_lat", + dtype="float64", + units="degrees_north", + nullable=False, + notes="station latitude copied at ingest time (provenance)", + ), + ColumnSpec( + name="station_lon", + dtype="float64", + units="degrees_east", + nullable=False, + notes="station longitude copied at ingest time (provenance)", + ), + ColumnSpec( + name="sat_lon_used", + dtype="float64", + units="degrees_east", + nullable=False, + notes="sub-satellite longitude read from goes_imager_projection", + ), + ColumnSpec( + name="source_object_key", + dtype="string", + units=None, + nullable=False, + notes="full S3/GCS object key the row was extracted from (provenance)", + ), + ColumnSpec( + name="ingested_at", + dtype="timestamp_utc", + units=None, + nullable=True, + notes="when the bytes were fetched; null for historical backfill", + ), + # --- SDK overlay (D2 identity + lineage, D5 qc, D4 leakage) ------ + ColumnSpec( + name="source", + dtype="string", + units=None, + nullable=False, + notes="per-row source-identity overlay == df.attrs['source'] == 'noaa_goes' (D2)", + ), + ColumnSpec( + name="delivery", + dtype="enum", + units=None, + nullable=False, + enum_values=SATELLITE_DELIVERY_VALUES, + notes="delivery-channel lineage {live,hosted}; NOT source identity (D2)", + ), + ColumnSpec( + name="qc_status", + dtype="enum", + units=None, + nullable=False, + enum_values=SATELLITE_QC_STATUS_VALUES, + notes="annotate-never-drop verdict (D5 severity-inverted)", + ), + ColumnSpec( + name="as_of_time", + dtype="timestamp_utc", + units=None, + nullable=True, + notes="knowledge-time stamp; nullable (D4)", + ), + ] + + +__all__ = [ + "SATELLITE_DELIVERY_VALUES", + "SATELLITE_QC_STATUS_VALUES", + "SatelliteSchema", + "validate_satellite_station", +] diff --git a/packages/core/tests/test_satellite_schema.py b/packages/core/tests/test_satellite_schema.py new file mode 100644 index 0000000..be15fee --- /dev/null +++ b/packages/core/tests/test_satellite_schema.py @@ -0,0 +1,148 @@ +"""``schema.satellite.v1`` registration + source-identity + codegen (Phase 25, P2-b). + +Verifies the SatelliteSchema is a real registered ``Schema`` (not just the ICAO +hook): singular ``_registered_source = "noaa_goes"`` (D2), the overlay columns +(``delivery``/``qc_status`` enums + nullable ``as_of_time``), registry lookup by +id, the source-identity validator reconciling ``df.attrs['source']`` + the +per-row ``source`` column, and deterministic codegen emission of +``schemas/json/schema.satellite.v1.json``. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +pd = pytest.importorskip("pandas") + +from mostlyright.core.exceptions import SourceMismatchError # noqa: E402 +from mostlyright.core.schemas import SCHEMA_REGISTRY, SatelliteSchema # noqa: E402 +from mostlyright.core.validator import validate_dataframe # noqa: E402 + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +SAT_JSON = PROJECT_ROOT / "schemas" / "json" / "schema.satellite.v1.json" +EXPORT_SCRIPT = PROJECT_ROOT / "scripts" / "export_schemas.py" + + +# --------------------------------------------------------------------------- +# Registration + D2 source identity (singular). +# --------------------------------------------------------------------------- +def test_schema_registered_by_id() -> None: + assert "schema.satellite.v1" in SCHEMA_REGISTRY + assert SCHEMA_REGISTRY["schema.satellite.v1"] is SatelliteSchema + + +def test_registered_source_is_singular_noaa_goes() -> None: + assert SatelliteSchema._registered_source == "noaa_goes" + # D2: SINGULAR string, not a frozenset/union. + assert isinstance(SatelliteSchema._registered_source, str) + assert getattr(SatelliteSchema, "_registered_sources", None) is None + + +def test_overlay_columns_present() -> None: + by_name = {c.name: c for c in SatelliteSchema.COLUMNS} + assert by_name["delivery"].dtype == "enum" + assert set(by_name["delivery"].enum_values) == {"live", "hosted"} + assert by_name["qc_status"].dtype == "enum" + assert set(by_name["qc_status"].enum_values) == {"clean", "flagged", "suspect"} + assert by_name["as_of_time"].dtype == "timestamp_utc" + assert by_name["as_of_time"].nullable is True + # The per-row source overlay the validator requires. + assert by_name["source"].nullable is False + + +# --------------------------------------------------------------------------- +# Source-identity validator actually executes for satellite rows. +# --------------------------------------------------------------------------- +def _valid_frame() -> pd.DataFrame: + now = pd.Timestamp("2024-06-02T00:00:00Z") + scan = pd.Timestamp("2024-06-01T18:00:00Z") + df = pd.DataFrame( + [ + { + "station": "KNYC", + "satellite": "goes16", + "product": "ABI-L2-ACMC", + "variable": "ACM", + "pressure_level_hpa": None, + "scan_start_utc": scan, + "scan_end_utc": scan, + "pixel_value": 1.0, + "pixel_dqf": None, + "pixel_row": 1, + "pixel_col": 2, + "units": "", + "station_lat": 40.7789, + "station_lon": -73.9692, + "sat_lon_used": -75.0, + "source_object_key": "ABI-L2-ACMC/2024/153/18/x.nc", + "ingested_at": now, + "source": "noaa_goes", + "delivery": "live", + "qc_status": "clean", + "as_of_time": now, + } + ] + ) + # All-None columns infer object dtype in a 1-row frame; cast the nullable + # float columns so the frame reflects a real (multi-row) satellite pull. + df["pressure_level_hpa"] = df["pressure_level_hpa"].astype("float64") + df["pixel_dqf"] = df["pixel_dqf"].astype("float64") + df.attrs["source"] = "noaa_goes" + df.attrs["retrieved_at"] = now.to_pydatetime() + return df + + +def test_validate_accepts_well_formed_noaa_goes_frame() -> None: + reg = validate_dataframe(_valid_frame(), "schema.satellite.v1") + assert reg is not None + + +def test_validate_rejects_source_drift() -> None: + df = _valid_frame() + df.attrs["source"] = "noaa_bdp" + df["source"] = "noaa_bdp" + with pytest.raises(SourceMismatchError): + validate_dataframe(df, "schema.satellite.v1") + + +def test_validate_rejects_per_row_source_mismatch() -> None: + df = _valid_frame() + # df.attrs stays noaa_goes but a row's per-row source drifts. + df.loc[0, "source"] = "noaa_bdp" + with pytest.raises(SourceMismatchError): + validate_dataframe(df, "schema.satellite.v1") + + +# --------------------------------------------------------------------------- +# Codegen: deterministic schema.satellite.v1.json emission. +# --------------------------------------------------------------------------- +@pytest.fixture(scope="module", autouse=True) +def regenerate_schemas() -> None: + subprocess.run( + ["uv", "run", "python", str(EXPORT_SCRIPT)], + cwd=PROJECT_ROOT, + check=True, + ) + + +def test_codegen_emits_satellite_json() -> None: + assert SAT_JSON.exists(), f"expected codegen output at {SAT_JSON}" + + +def test_codegen_id_uses_dev_namespace() -> None: + data = json.loads(SAT_JSON.read_text()) + assert data["$id"] == "https://mostlyright.dev/schemas/schema.satellite.v1.json" + assert data["title"] == "schema.satellite.v1" + + +def test_codegen_deterministic() -> None: + """Running the exporter --check (twice in memory) is byte-stable.""" + rc = subprocess.run( + ["uv", "run", "python", str(EXPORT_SCRIPT), "--check"], + cwd=PROJECT_ROOT, + ) + assert rc.returncode == 0 diff --git a/packages/weather/pyproject.toml b/packages/weather/pyproject.toml index 9fcddaf..479efc5 100644 --- a/packages/weather/pyproject.toml +++ b/packages/weather/pyproject.toml @@ -96,6 +96,22 @@ polars = [ "pandas>=2.2,<4.0", "pyarrow>=17.0,<24.0", ] +# Phase 25: GOES ABI L2 satellite ingest (free local tier). Reads anonymous +# public NOAA NODD buckets (s3://noaa-goes16 / noaa-goes19, AWS) or the GCS +# mirror — no hosted backend, no api.mostlyright.md. Whole-file reads via +# `h5netcdf` (ships `libhdf5` as a wheel; no system install). `xarray` is the +# dataset API (shared floor with `[nwp]`). `boto3`/`s3fs` do anonymous +# (UNSIGNED) S3; `gcsfs` does the anonymous GCS mirror (D9). `numpy` powers +# the ABI scan-angle projection inversion (replaces scikit-learn BallTree). +satellite = [ + "boto3>=1.34,<2.0", + "s3fs>=2024.0", + "gcsfs>=2024.0", + "h5netcdf>=1.3", + "xarray>=2024.0", + "numpy>=1.24", + "pandas>=2.2,<4.0", +] [build-system] requires = ["hatchling"] diff --git a/packages/weather/src/mostlyright/weather/__init__.py b/packages/weather/src/mostlyright/weather/__init__.py index 9afdcb1..7a39498 100644 --- a/packages/weather/src/mostlyright/weather/__init__.py +++ b/packages/weather/src/mostlyright/weather/__init__.py @@ -32,6 +32,7 @@ from mostlyright.weather._fetchers._open_meteo import fetch_open_meteo from mostlyright.weather.obs import obs as obs # re-export Phase 7 public surface +from mostlyright.weather.satellite import satellite as satellite # Phase 25 surface __version__ = "0.1.0rc1" -__all__ = ["__version__", "fetch_open_meteo", "obs"] +__all__ = ["__version__", "fetch_open_meteo", "obs", "satellite"] diff --git a/packages/weather/src/mostlyright/weather/_fetchers/_goes_extract.py b/packages/weather/src/mostlyright/weather/_fetchers/_goes_extract.py new file mode 100644 index 0000000..ff99e27 --- /dev/null +++ b/packages/weather/src/mostlyright/weather/_fetchers/_goes_extract.py @@ -0,0 +1,981 @@ +"""GOES-16/19 ABI Level-2 satellite extractor — single-pixel, pure CPU. + +Byte-faithful port of the 2i monorepo ``goes_satellite.py`` extraction engine +(Phase 25 Wave 1). This module is NETWORK-FREE: ``_extract_from_dataset`` takes +an already-open :class:`xarray.Dataset`. The whole-file S3/GCS transport lives +in 25-03 (``_goes_s3.py``). + +For each (product, variable) entry in the PRODUCTS registry, extracts: + (1) ``pixel_value`` — the single NetCDF grid cell at the station's exact + lat/lon, after applying scale_factor/add_offset. RAW-AS-REPORTED, NO + FILTERING. DQF-bad and out-of-valid_range values are STILL stored. + ``pixel_value`` is null ONLY when the raw NetCDF value equals the + ``_FillValue`` attribute (i.e. NOAA explicitly marked no data at that + cell). Consumer filters using ``pixel_dqf`` + ``registry.bounds``. + (2) ``pixel_dqf`` — the NOAA DQF integer at the station cell, unchanged. + Null for variables that have no DQF (e.g. BCM, ACM). + (3) ``source_object_key`` — the full S3 key of the NetCDF file this row + came from. Provenance for re-opening the exact source file later. + +One row per raw scan, per variable, per pressure level (for 3D products). + +Projection parameters are read from NetCDF ``goes_imager_projection`` attributes +on EVERY file read — NEVER hard-coded. + +PHASE 25 EDITS to the verbatim port (and ONLY these): + (a) ``_build_record`` writes ``station.icao`` (D2 ICAO identity, validated via + ``validate_satellite_station``) and stamps ``delivery``/``qc_status``/ + ``as_of_time`` (the 2i ``source: "goes_s3"`` becomes the ``delivery`` + lineage enum; ``source_object_key`` stays for S3 provenance). + (b) the typed exceptions import from ``mostlyright.core.exceptions``. + (c) P2-c — a per-variable units mismatch in ``_extract_from_dataset`` emits a + ``qc_status="suspect"`` row and CONTINUES the loop (D5 annotate-never-drop) + instead of raising ``UnitsContractError`` and aborting the scan. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Any, Literal + +import numpy as np +import xarray as xr +from mostlyright._internal._stations import StationInfo +from mostlyright.core.exceptions import ( + GoesDataCorruptError, + GoesS3Error, # noqa: F401 (re-exported for 25-03's transport layer) + ProductNotRegisteredError, # noqa: F401 (re-exported for the public fetcher) + StationOutOfGridError, + UnitsContractError, # noqa: F401 (still importable for unrecoverable cases) +) +from mostlyright.core.schemas.satellite import validate_satellite_station + +log = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Registry dataclass + PRODUCTS dict +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class ProductVariable: + """Metadata for one (product, variable) pair. + + Every field is load-bearing. Adding a new variable from an existing + product = one new ProductVariable entry keyed on (product_code, var_name). + """ + + product_code: str + variable: str + units_expected: str + grid_shape_expected: tuple[int, int] + cadence_s: int + bounds: tuple[float, float] + dqf_good: frozenset[int] | None + dqf_variable: str | None + is_3d_profile: bool + is_categorical: bool + description: str + tier: Literal["v1", "v2", "v3"] + # Whether to apply the NetCDF ``valid_range`` attribute as a pixel + # filter (converting it from raw-integer-units to physical units via + # ``raw * scale + offset``). True for most products where NOAA stores + # valid_range in raw dtype units (LST, DSR, CAPE, TPW, etc.). False for + # Cloud_Probabilities where NOAA violates the CF-1.8 convention and + # stores valid_range=[0, 1] already in physical units — applying the + # scale transform there collapses the range to [0, 1.5e-5] and rejects + # every real data point. Default True (safe for all known v1 + most v2 + # products). Verified against real file on 2026-04-11 in sprint2i live + # verification gate. See _apply_valid_range docstring for full context. + valid_range_filter: bool = True + + +PRODUCTS: dict[tuple[str, str], ProductVariable] = { + # ==================== v1: ABI-L2-ACMC (Clear Sky Mask CONUS) ==================== + ("ABI-L2-ACMC", "BCM"): ProductVariable( + product_code="ABI-L2-ACMC", + variable="BCM", + units_expected="1", + grid_shape_expected=(1500, 2500), + cadence_s=300, + bounds=(0.0, 1.0), + dqf_good=None, + dqf_variable="DQF", + is_3d_profile=False, + is_categorical=False, + description=( + "Binary Cloud Mask — single pixel value (BCM==1 means cloudy per GOES-R PUG Vol 5)" + ), + tier="v1", + ), + ("ABI-L2-ACMC", "ACM"): ProductVariable( + product_code="ABI-L2-ACMC", + variable="ACM", + # Real ACMC files have NO `units` attribute on the ACM variable. + # Extraction code reads this as the empty string. The earlier "n/a" + # value in the registry was a misread of goes_discover.py's default + # sentinel (attrs.get('units', 'n/a')) — verified against real file + # noaa-goes16/ABI-L2-ACMC/2024/167/12/OR_ABI-L2-ACMC-M6_G16_s20241671201178_...nc + # on 2026-04-11 during the sprint2i live verification gate. + units_expected="", + grid_shape_expected=(1500, 2500), + cadence_s=300, + bounds=(0.0, 3.0), + dqf_good=None, + dqf_variable="DQF", + is_3d_profile=False, + is_categorical=True, + description=( + "4-level cloud mask (clear / probably clear / probably cloudy / cloudy) — " + "pixel_value is the single grid cell's integer category cast to float" + ), + tier="v1", + ), + ("ABI-L2-ACMC", "Cloud_Probabilities"): ProductVariable( + product_code="ABI-L2-ACMC", + variable="Cloud_Probabilities", + # Same missing-units situation as ACM — verified against real file on 2026-04-11. + units_expected="", + grid_shape_expected=(1500, 2500), + cadence_s=300, + bounds=(0.0, 1.0), + dqf_good=frozenset({0}), + dqf_variable="DQF", + is_3d_profile=False, + is_categorical=False, + description=( + "Per-pixel cloud probability from the cloud mask algorithm — raw value " + "at station cell stored in pixel_value (DQF stored in pixel_dqf for " + "consumer-side filtering)" + ), + tier="v1", + # Cloud_Probabilities' NetCDF valid_range=[0, 1] is declared in + # PHYSICAL units (not raw uint16 units) despite CF-1.8 saying the + # opposite, so scaling it via raw*scale+offset collapses the range + # to [0, 1.5e-5] and rejects every real data point. Disable the + # valid_range filter for this variable — registry.bounds (0.0, 1.0) + # still catches out-of-range values at validation and audit time. + # Verified 2026-04-11 against real file + # noaa-goes16/ABI-L2-ACMC/2024/167/12/OR_ABI-L2-ACMC-M6_G16_s20241671201178_...nc + valid_range_filter=False, + ), + # ==================== v1: ABI-L2-LSTC ==================== + ("ABI-L2-LSTC", "LST"): ProductVariable( + product_code="ABI-L2-LSTC", + variable="LST", + units_expected="K", + grid_shape_expected=(1500, 2500), + cadence_s=3600, + # Widened from [180, 350] to [100, 350] — real nighttime cold-scene + # LST retrievals from GOES-16 go below 180 K (observed 146-177 K in + # 2017 data during sprint2i backfill). 100 K (-173°C) is still a + # safe physical floor that catches garbage values. + bounds=(100.0, 350.0), + dqf_good=frozenset({0, 1}), + dqf_variable="DQF", + is_3d_profile=False, + is_categorical=False, + description=("Land Surface (Skin) Temperature — single pixel at station lat/lon"), + tier="v1", + ), + # ==================== v1: ABI-L2-TPWC ==================== + ("ABI-L2-TPWC", "TPW"): ProductVariable( + product_code="ABI-L2-TPWC", + variable="TPW", + units_expected="mm", + grid_shape_expected=(300, 500), + cadence_s=300, + bounds=(0.0, 80.0), + dqf_good=frozenset({0}), + dqf_variable="DQF_Overall", + is_3d_profile=False, + is_categorical=False, + description=("Total Precipitable Water column — single pixel at station lat/lon"), + tier="v1", + ), + # ==================== v1: ABI-L2-DSIC ==================== + ("ABI-L2-DSIC", "LI"): ProductVariable( + product_code="ABI-L2-DSIC", + variable="LI", + units_expected="K", + grid_shape_expected=(300, 500), + cadence_s=300, + bounds=(-20.0, 40.0), + dqf_good=frozenset({0}), + dqf_variable="DQF_Overall", + is_3d_profile=False, + is_categorical=False, + description="Lifted Index (negative = unstable, positive = stable)", + tier="v1", + ), + ("ABI-L2-DSIC", "CAPE"): ProductVariable( + product_code="ABI-L2-DSIC", + variable="CAPE", + units_expected="J kg-1", + grid_shape_expected=(300, 500), + cadence_s=300, + bounds=(0.0, 8000.0), + dqf_good=frozenset({0}), + dqf_variable="DQF_Overall", + is_3d_profile=False, + is_categorical=False, + description="Convective Available Potential Energy", + tier="v1", + ), + ("ABI-L2-DSIC", "TT"): ProductVariable( + product_code="ABI-L2-DSIC", + variable="TT", + units_expected="K", + grid_shape_expected=(300, 500), + cadence_s=300, + bounds=(-50.0, 80.0), + dqf_good=frozenset({0}), + dqf_variable="DQF_Overall", + is_3d_profile=False, + is_categorical=False, + description="Total Totals Index — thunderstorm severity proxy", + tier="v1", + ), + ("ABI-L2-DSIC", "SI"): ProductVariable( + product_code="ABI-L2-DSIC", + variable="SI", + units_expected="K", + grid_shape_expected=(300, 500), + cadence_s=300, + bounds=(-20.0, 40.0), + dqf_good=frozenset({0}), + dqf_variable="DQF_Overall", + is_3d_profile=False, + is_categorical=False, + description="Showalter Index", + tier="v1", + ), + ("ABI-L2-DSIC", "KI"): ProductVariable( + product_code="ABI-L2-DSIC", + variable="KI", + units_expected="K", + grid_shape_expected=(300, 500), + cadence_s=300, + bounds=(-40.0, 80.0), + dqf_good=frozenset({0}), + dqf_variable="DQF_Overall", + is_3d_profile=False, + is_categorical=False, + description="K-Index", + tier="v1", + ), + # ==================== v1: ABI-L2-DSRF ==================== + ("ABI-L2-DSRF", "DSR"): ProductVariable( + product_code="ABI-L2-DSRF", + variable="DSR", + units_expected="W m-2", + grid_shape_expected=(5424, 5424), + cadence_s=600, + bounds=(0.0, 1400.0), + dqf_good=frozenset({0}), + dqf_variable="DQF", + is_3d_profile=False, + is_categorical=False, + description=("Downward Shortwave Radiation at the surface (full-disk product only)"), + tier="v1", + ), + # ==================== v2 ==================== + ("ABI-L2-ACHAC", "HT"): ProductVariable( + product_code="ABI-L2-ACHAC", + variable="HT", + units_expected="m", + grid_shape_expected=(300, 500), + cadence_s=600, + bounds=(0.0, 20000.0), + dqf_good=frozenset({0}), + dqf_variable="DQF", + is_3d_profile=False, + is_categorical=False, + description="Cloud Top Height above ellipsoid", + tier="v2", + ), + ("ABI-L2-CTPC", "PRES"): ProductVariable( + product_code="ABI-L2-CTPC", + variable="PRES", + units_expected="hPa", + grid_shape_expected=(300, 500), + cadence_s=600, + bounds=(50.0, 1050.0), + dqf_good=frozenset({0}), + dqf_variable="DQF", + is_3d_profile=False, + is_categorical=False, + description="Cloud Top Pressure — single pixel at station lat/lon", + tier="v2", + ), + ("ABI-L2-AODC", "AOD"): ProductVariable( + product_code="ABI-L2-AODC", + variable="AOD", + units_expected="1", + grid_shape_expected=(1500, 2500), + cadence_s=3600, + bounds=(0.0, 5.0), + dqf_good=frozenset({0, 1}), + dqf_variable="DQF", + is_3d_profile=False, + is_categorical=False, + description="Aerosol Optical Depth at 550 nm", + tier="v2", + ), + ("ABI-L2-AODC", "AE1"): ProductVariable( + product_code="ABI-L2-AODC", + variable="AE1", + units_expected="n/a", + grid_shape_expected=(1500, 2500), + cadence_s=3600, + bounds=(-2.0, 4.0), + dqf_good=frozenset({0, 1}), + dqf_variable="AE_DQF", + is_3d_profile=False, + is_categorical=False, + description="Angstrom Exponent 0.47/0.86 μm", + tier="v2", + ), + ("ABI-L2-AODC", "AE2"): ProductVariable( + product_code="ABI-L2-AODC", + variable="AE2", + units_expected="n/a", + grid_shape_expected=(1500, 2500), + cadence_s=3600, + bounds=(-2.0, 4.0), + dqf_good=frozenset({0, 1}), + dqf_variable="AE_DQF", + is_3d_profile=False, + is_categorical=False, + description="Angstrom Exponent 0.86/1.61 μm", + tier="v2", + ), + # ==================== v3: 3D profile products ==================== + ("ABI-L2-LVMPC", "LVM"): ProductVariable( + product_code="ABI-L2-LVMPC", + variable="LVM", + units_expected="percent", + grid_shape_expected=(300, 500), + cadence_s=3600, + bounds=(0.0, 100.0), + dqf_good=frozenset({0}), + dqf_variable="DQF_Overall", + is_3d_profile=True, + is_categorical=False, + description=("Legacy Vertical Moisture Profile — relative humidity per pressure level"), + tier="v3", + ), + ("ABI-L2-LVTPC", "LVT"): ProductVariable( + product_code="ABI-L2-LVTPC", + variable="LVT", + units_expected="K", + grid_shape_expected=(300, 500), + cadence_s=3600, + bounds=(150.0, 350.0), + dqf_good=frozenset({0}), + dqf_variable="DQF_Overall", + is_3d_profile=True, + is_categorical=False, + description=("Legacy Vertical Temperature Profile — air temperature per pressure level"), + tier="v3", + ), +} + + +#: Cheap up-front validation surface for the public fetcher (25-04): the set of +#: registered product codes derived from the PRODUCTS keys. +_KNOWN_PRODUCTS: set[str] = {p for (p, _v) in PRODUCTS} + + +def get_product_variable(product: str, variable: str) -> ProductVariable | None: + """Return the ProductVariable for (product, variable), or None if absent.""" + return PRODUCTS.get((product, variable)) + + +def products_in_tier(tier: str) -> list[str]: + """Return sorted list of unique product codes enabled for a given scope tier.""" + return sorted({pv.product_code for pv in PRODUCTS.values() if pv.tier == tier}) + + +def variables_for_product(product: str) -> list[str]: + """Return list of variable names registered for a given product code.""" + return sorted(v for (p, v) in PRODUCTS if p == product) + + +# --------------------------------------------------------------------------- +# Projection and grid (read from NetCDF file) +# --------------------------------------------------------------------------- + + +def _read_projection_params(ds: xr.Dataset) -> dict[str, float]: + """Read GOES fixed-grid projection params from ``goes_imager_projection``. + + Raises ``GoesDataCorruptError`` on missing variable or missing attrs. + Returns a dict that includes the derived ``eccentricity``. + """ + if "goes_imager_projection" not in ds.variables: + raise GoesDataCorruptError("NetCDF missing goes_imager_projection variable") + proj = ds["goes_imager_projection"] + required = [ + "perspective_point_height", + "semi_major_axis", + "semi_minor_axis", + "longitude_of_projection_origin", + ] + params: dict[str, float] = {} + for key in required: + if key not in proj.attrs: + raise GoesDataCorruptError(f"goes_imager_projection missing attr: {key}") + params[key] = float(proj.attrs[key]) + r_eq = params["semi_major_axis"] + r_pol = params["semi_minor_axis"] + params["eccentricity"] = float(np.sqrt(1.0 - (r_pol / r_eq) ** 2)) + return params + + +def _read_grid_params(ds: xr.Dataset) -> dict[str, Any]: + """Read x/y coord scale/offset/size. Raises ``GoesDataCorruptError``.""" + if "x" not in ds.coords or "y" not in ds.coords: + raise GoesDataCorruptError("NetCDF missing x/y coordinate") + x_coord = ds.coords["x"] + y_coord = ds.coords["y"] + for attr in ("scale_factor", "add_offset"): + if attr not in x_coord.attrs or attr not in y_coord.attrs: + raise GoesDataCorruptError(f"x/y coordinate missing required attr: {attr}") + return { + "x_scale": float(x_coord.attrs["scale_factor"]), + "x_offset": float(x_coord.attrs["add_offset"]), + "y_scale": float(y_coord.attrs["scale_factor"]), + "y_offset": float(y_coord.attrs["add_offset"]), + "nx": int(x_coord.size), + "ny": int(y_coord.size), + } + + +def latlon_to_abi_xy(lat_deg: float, lon_deg: float, proj: dict[str, float]) -> tuple[float, float]: + """Convert (lat, lon) to ABI fixed-grid scan angles. + + Reference: GOES-R PUG Volume 5, Appendix A. All parameters are sourced + from the NetCDF file; nothing is hard-coded. + """ + H = proj["perspective_point_height"] + proj["semi_major_axis"] + r_eq = proj["semi_major_axis"] + r_pol = proj["semi_minor_axis"] + e = proj["eccentricity"] + lam0 = np.radians(proj["longitude_of_projection_origin"]) + phi = np.radians(lat_deg) + lam = np.radians(lon_deg) + phi_c = np.arctan((r_pol / r_eq) ** 2 * np.tan(phi)) + r_c = r_pol / np.sqrt(1.0 - (e * np.cos(phi_c)) ** 2) + sx = H - r_c * np.cos(phi_c) * np.cos(lam - lam0) + sy = -r_c * np.cos(phi_c) * np.sin(lam - lam0) + sz = r_c * np.sin(phi_c) + y_scan = float(np.arctan(sz / sx)) + x_scan = float(np.arcsin(-sy / np.sqrt(sx**2 + sy**2 + sz**2))) + return x_scan, y_scan + + +def compute_pixel_indices(x_scan: float, y_scan: float, grid: dict[str, Any]) -> tuple[int, int]: + """Scan angles → (row, col). Raises ``StationOutOfGridError`` if outside.""" + col = int(round((x_scan - grid["x_offset"]) / grid["x_scale"])) + row = int(round((y_scan - grid["y_offset"]) / grid["y_scale"])) + if not (0 <= col < grid["nx"] and 0 <= row < grid["ny"]): + raise StationOutOfGridError( + f"Station projection ({x_scan:.6f}, {y_scan:.6f}) -> " + f"pixel ({row}, {col}) outside grid ({grid['ny']}x{grid['nx']})" + ) + return row, col + + +# --------------------------------------------------------------------------- +# Lat/lon projection (used by goes16 L2 DSRF — regular lat/lon grid) +# --------------------------------------------------------------------------- +# +# Most ABI L2 products ship on the ABI fixed-grid (geostationary perspective, +# decoded via ``_read_projection_params`` + ``latlon_to_abi_xy``). But +# goes16's DSRF (Downward Shortwave Radiation at the surface) publishes on a +# regular lat/lon grid (326×326 covering roughly -156.5° to 6.5° longitude +# and ±81.5° latitude). The file's projection metadata is +# ``goes_lat_lon_projection`` with ``grid_mapping_name='latitude_longitude'``, +# and the data variable has dims ``(lat, lon)`` instead of ``(y, x)``. +# +# goes19 DSRF switched back to the ABI fixed grid at 5424×5424, so the +# extractor needs to handle BOTH projections and branch per file. + + +def _read_lat_lon_projection_params(ds: xr.Dataset) -> dict[str, float]: + """Read ``goes_lat_lon_projection`` + nadir longitude. + + Returns a dict shaped like :func:`_read_projection_params` so + ``_build_record`` can still record ``sat_lon_used`` for provenance. + Used for goes16 DSRF (regular lat/lon grid). + """ + if "goes_lat_lon_projection" not in ds.variables: + raise GoesDataCorruptError("NetCDF missing goes_lat_lon_projection variable") + proj = ds["goes_lat_lon_projection"] + params: dict[str, float] = { + "semi_major_axis": float(proj.attrs.get("semi_major_axis", 6378137.0)), + "semi_minor_axis": float(proj.attrs.get("semi_minor_axis", 6356752.31414)), + # Placeholder — refined below if the file carries nadir longitude. + "longitude_of_projection_origin": 0.0, + } + if "geospatial_lat_lon_extent" in ds.variables: + extent = ds["geospatial_lat_lon_extent"] + if "geospatial_lon_nadir" in extent.attrs: + params["longitude_of_projection_origin"] = float(extent.attrs["geospatial_lon_nadir"]) + return params + + +def _read_lat_lon_grid(ds: xr.Dataset) -> dict[str, Any]: + """Read decoded ``lat``/``lon`` coord arrays for lat/lon projection files. + + Returns actual (not raw-packed) latitude and longitude values per row and + column. Required for ``latlon_to_ll_pixel`` nearest-index lookup. + """ + for name in ("lat", "lon"): + if name not in ds.coords: + raise GoesDataCorruptError(f"lat/lon grid missing '{name}' coord") + lat_c = ds.coords["lat"] + lon_c = ds.coords["lon"] + for coord, name in ((lat_c, "lat"), (lon_c, "lon")): + for attr in ("scale_factor", "add_offset"): + if attr not in coord.attrs: + raise GoesDataCorruptError(f"{name} coord missing required attr: {attr}") + scale_lat = float(lat_c.attrs["scale_factor"]) + off_lat = float(lat_c.attrs["add_offset"]) + scale_lon = float(lon_c.attrs["scale_factor"]) + off_lon = float(lon_c.attrs["add_offset"]) + lat_vals = lat_c.values.astype(np.float64) * scale_lat + off_lat + lon_vals = lon_c.values.astype(np.float64) * scale_lon + off_lon + return { + "lat_vals": lat_vals, + "lon_vals": lon_vals, + "n_lat": int(lat_c.size), + "n_lon": int(lon_c.size), + } + + +def latlon_to_ll_pixel(lat_deg: float, lon_deg: float, grid: dict[str, Any]) -> tuple[int, int]: + """Find the nearest ``(row, col)`` in a regular lat/lon grid. + + Raises :class:`StationOutOfGridError` if the station falls outside the + grid lat/lon bounds — no extrapolation. + """ + lat_vals = grid["lat_vals"] + lon_vals = grid["lon_vals"] + lat_min, lat_max = float(lat_vals.min()), float(lat_vals.max()) + lon_min, lon_max = float(lon_vals.min()), float(lon_vals.max()) + if not (lat_min <= lat_deg <= lat_max) or not (lon_min <= lon_deg <= lon_max): + raise StationOutOfGridError( + f"Station ({lat_deg:.3f}, {lon_deg:.3f}) outside lat/lon grid " + f"[{lat_min:.2f}..{lat_max:.2f}] × [{lon_min:.2f}..{lon_max:.2f}]" + ) + row = int(np.argmin(np.abs(lat_vals - lat_deg))) + col = int(np.argmin(np.abs(lon_vals - lon_deg))) + return row, col + + +# --------------------------------------------------------------------------- +# Filename parsing +# --------------------------------------------------------------------------- + +_TS_START_RE = re.compile(r"_s(\d{14})_") +_TS_END_RE = re.compile(r"_e(\d{14})_") + + +def _parse_goes_ts(ts_field: str) -> datetime: + year = int(ts_field[0:4]) + doy = int(ts_field[4:7]) + hh, mm, ss = int(ts_field[7:9]), int(ts_field[9:11]), int(ts_field[11:13]) + return datetime(year, 1, 1, tzinfo=UTC) + timedelta( + days=doy - 1, hours=hh, minutes=mm, seconds=ss + ) + + +def parse_scan_times(s3_key: str) -> tuple[datetime, datetime]: + """Parse scan start/end from a standard GOES filename. + + Raises ``ValueError`` if either timestamp is missing. + """ + fname = s3_key.rsplit("/", 1)[-1] + m_start = _TS_START_RE.search(fname) + m_end = _TS_END_RE.search(fname) + if not m_start: + raise ValueError(f"Cannot parse scan start from: {fname}") + if not m_end: + raise ValueError(f"Cannot parse scan end from: {fname}") + return _parse_goes_ts(m_start.group(1)), _parse_goes_ts(m_end.group(1)) + + +# --------------------------------------------------------------------------- +# Spatial extraction helpers +# --------------------------------------------------------------------------- + + +def _apply_scale_offset(raw: np.ndarray, attrs: dict[str, Any]) -> np.ndarray: + """Apply NetCDF ``_FillValue`` → NaN plus ``_Unsigned`` reinterpretation + plus ``scale_factor``/``add_offset`` manually. + + ``_Unsigned='true'`` tells us the raw signed dtype (e.g. int16) should + be interpreted as the unsigned equivalent (uint16). Some NOAA GOES L2 + files use this convention — notably goes16 DSRF where DSR is stored + as int16 with _Unsigned='true', so a raw value of -31738 is actually + the uint16 value 33798. Without this reinterpretation, scale+offset + produces negative physical values that are clearly wrong (DSR values + around -600 W/m² instead of +600 W/m²). + + The FillValue check runs on the original raw dtype so NOAA's + ``_FillValue`` (e.g. -1 for int16, which means "uint16 65535") + matches correctly regardless of reinterpretation. + """ + fill = attrs.get("_FillValue") + # Capture fill mask BEFORE any reinterpretation. + fill_mask = None + if fill is not None: + fill_mask = raw == fill + + # Honor _Unsigned convention. NetCDF CF-1.8 / Appendix A says this + # attribute is typically the string "true" or "false". + unsigned_attr = attrs.get("_Unsigned") + if isinstance(unsigned_attr, bytes): + unsigned_attr = unsigned_attr.decode("ascii", errors="replace") + is_unsigned = isinstance(unsigned_attr, str) and unsigned_attr.strip().lower() == "true" + + if is_unsigned and np.issubdtype(raw.dtype, np.signedinteger): + # Reinterpret bit pattern as the unsigned equivalent. int16 → uint16, + # int32 → uint32, int64 → uint64. This is a no-op for already-unsigned + # or float data (we don't change precision). + unsigned_dtype = np.dtype(f"u{raw.dtype.itemsize}") + arr = raw.view(unsigned_dtype).astype(np.float64) + else: + arr = raw.astype(np.float64) + + if fill_mask is not None: + arr = np.where(fill_mask, np.nan, arr) + + scale = float(attrs.get("scale_factor", 1.0)) + offset = float(attrs.get("add_offset", 0.0)) + if scale != 1.0 or offset != 0.0: + arr = arr * scale + offset + return arr + + +def _apply_valid_range( + arr: np.ndarray, + var_attrs: dict[str, Any], + enabled: bool = True, +) -> np.ndarray: + """Mask cells outside ``valid_range`` to NaN (per-product opt-in). + + The CF-1.8 convention states that ``valid_range`` applies to the DATA + VARIABLE's raw dtype BEFORE ``scale_factor``/``add_offset`` is applied, + but NOAA GOES-R ABI L2 files are inconsistent about this: + + - LST/DSR/CAPE/TPW etc.: ``valid_range`` is in raw integer units, and + scaling it via ``raw * scale + offset`` gives the correct physical + envelope (e.g. LST ``[9200, 61200]`` → ``[213, 343] K``). These + products use ``valid_range_filter=True`` in the registry (the + default) so the filter is active. + - Cloud_Probabilities: ``valid_range=[0, 1]`` is already in PHYSICAL + units despite the raw dtype being ``uint16`` with a small + ``scale_factor`` — verified 2026-04-11 against real file + ``noaa-goes16/ABI-L2-ACMC/2024/167/12/OR_ABI-L2-ACMC-M6_G16_s20241671201178_...nc``. + Applying the same ``raw * scale + offset`` transform yields + ``[0, 1.5e-5]``, which rejects every real data point. The registry + sets ``valid_range_filter=False`` for Cloud_Probabilities so this + function becomes a pass-through for it. + + Defense-in-depth is always provided by: + (1) ``_FillValue`` → NaN in ``_apply_scale_offset``, which catches + the dominant "no data here" case. + (2) ``registry.bounds`` at ``_validate_satellite`` and + ``ingest.satellite_audit._count_physics_violations_satellite``, + which reject physically impossible values at ingest and audit. + """ + if not enabled: + return arr + vr = var_attrs.get("valid_range") + if vr is None: + return arr + scale = float(var_attrs.get("scale_factor", 1.0)) + offset = float(var_attrs.get("add_offset", 0.0)) + lo = float(vr[0]) * scale + offset + hi = float(vr[1]) * scale + offset + return np.where((arr >= lo) & (arr <= hi), arr, np.nan) + + +def _read_pixel_dqf( + ds: xr.Dataset, + pv: ProductVariable, + pixel_row: int, + pixel_col: int, + isel_kwargs: dict[str, int] | None = None, +) -> int | None: + """Read the single-pixel DQF value for one variable. + + Returns the integer DQF at (pixel_row, pixel_col), or None when the + registry declares no DQF filtering (e.g. BCM / ACM). Raises + ``GoesDataCorruptError`` if the registry declares a DQF variable the file + doesn't have. + + ``isel_kwargs`` maps dim name → index for the station pixel. Defaults to + the ABI fixed-grid naming ``{"y": row, "x": col}``. For goes16 DSRF and + other lat/lon projection files, pass ``{"lat": row, "lon": col}``. + """ + if pv.dqf_good is None or pv.dqf_variable is None: + return None + if pv.dqf_variable not in ds.variables: + raise GoesDataCorruptError( + f"registry declares dqf_variable={pv.dqf_variable} but NetCDF has none" + ) + if isel_kwargs is None: + isel_kwargs = {"y": pixel_row, "x": pixel_col} + dqf_val = ds[pv.dqf_variable].isel(**isel_kwargs).values + return int(dqf_val) + + +def _build_record( + *, + pixel_value_scaled: float, + pixel_row: int, + pixel_col: int, + pixel_dqf: int | None, + pv: ProductVariable, + station: StationInfo, + satellite: str, + product: str, + variable: str, + pressure_level_hpa: float | None, + scan_start_str: str, + scan_end_str: str, + units: str, + source_object_key: str, + ingested_at: str | None, + proj: dict[str, float], + qc_status: str = "clean", + as_of_time: str | None = None, +) -> dict[str, Any]: + """Build a single satellite record dict (single-pixel raw-as-reported). + + PHASE 25 EDITS vs the 2i verbatim port: + - ``station_code`` -> ``station`` carrying ``station.icao`` (D2 ICAO + identity), validated by ``validate_satellite_station`` so a non-4-letter + station fails loudly at build time. + - the 2i ``source: "goes_s3"`` field is REPLACED by ``delivery`` (the + {live,hosted} lineage enum, default "live"); ``source_object_key`` stays + for S3 provenance. NO ``source`` row column is emitted here — the + ``noaa_goes`` source identity is stamped at the fetcher boundary (25-04). + - ``qc_status``/``as_of_time`` keys are present (defaulted here; finalized + by the orchestrator in 25-04). + """ + icao = validate_satellite_station(station.icao) + if np.isnan(pixel_value_scaled): + pixel_value: float | None = None + else: + pixel_value = float(pixel_value_scaled) + + return { + "station": icao, + "satellite": satellite, + "product": product, + "variable": variable, + "pressure_level_hpa": pressure_level_hpa, + "scan_start_utc": scan_start_str, + "scan_end_utc": scan_end_str, + "delivery": "live", + "source_object_key": source_object_key, + "ingested_at": ingested_at, + "pixel_value": pixel_value, + "pixel_dqf": pixel_dqf, + "pixel_row": int(pixel_row), + "pixel_col": int(pixel_col), + "units": units, + "station_lat": float(station.latitude), + "station_lon": float(station.longitude), + "sat_lon_used": float(proj["longitude_of_projection_origin"]), + "qc_status": qc_status, + "as_of_time": as_of_time, + } + + +def _extract_from_dataset( + ds: xr.Dataset, + *, + s3_key: str, + product: str, + station: StationInfo, + satellite: str, + ingested_at: str | None, + var_entries: list[ProductVariable], +) -> list[dict[str, Any]]: + """Core extraction given an already-open Dataset (single-pixel). + + Reads only the single grid cell at the station's lat/lon for each + registered variable. No patch, no neighborhood, no aggregation. + + Separated so the retry loop only wraps the S3-open path. + + PHASE 25 EDIT (P2-c, D5 annotate-never-drop): a per-variable units mismatch + no longer raises ``UnitsContractError`` (which aborted the whole scan and + lost every remaining variable's rows). Instead it emits a schema-valid row + for THAT variable carrying ``qc_status="suspect"`` (with the offending units + recorded) and CONTINUES the loop. The remaining variables still produce + rows; no variable is dropped, and no ``UnitsContractError`` propagates out. + """ + scan_start, scan_end = parse_scan_times(s3_key) + scan_start_str = scan_start.strftime("%Y-%m-%dT%H:%M:%SZ") + scan_end_str = scan_end.strftime("%Y-%m-%dT%H:%M:%SZ") + + # Branch on the file's projection variable. Most ABI L2 products use + # the fixed ABI grid (goes_imager_projection). goes16 DSRF uses a + # regular lat/lon grid (goes_lat_lon_projection); goes19 DSRF is back + # on the ABI grid. Detecting from the file (not the product registry) + # keeps the extractor agnostic to which satellite produced the file. + if "goes_imager_projection" in ds.variables: + proj = _read_projection_params(ds) + grid = _read_grid_params(ds) + x_scan, y_scan = latlon_to_abi_xy(station.latitude, station.longitude, proj) + pixel_row, pixel_col = compute_pixel_indices(x_scan, y_scan, grid) + expected_2d = ("y", "x") + expected_3d = ("y", "x", "pressure") + isel_kwargs = {"y": pixel_row, "x": pixel_col} + elif "goes_lat_lon_projection" in ds.variables: + proj = _read_lat_lon_projection_params(ds) + grid = _read_lat_lon_grid(ds) + pixel_row, pixel_col = latlon_to_ll_pixel(station.latitude, station.longitude, grid) + expected_2d = ("lat", "lon") + # 3D profiles on a lat/lon grid are not expected for any registered + # product; if one shows up we fail loudly rather than silently + # reinterpret the pressure axis. + expected_3d = None + isel_kwargs = {"lat": pixel_row, "lon": pixel_col} + else: + raise GoesDataCorruptError( + "NetCDF has neither goes_imager_projection nor " + "goes_lat_lon_projection — no supported projection" + ) + + out_records: list[dict[str, Any]] = [] + for pv in var_entries: + var_name = pv.variable + if var_name not in ds.variables: + # Skip missing variables instead of aborting the entire file. + # Real-world GOES-16 files from different time periods have + # different variable sets (e.g. ACMC files from 2017 have only + # BCM+DQF, while 2024 files have BCM+ACM+Cloud_Probabilities+DQF). + # Raising GoesDataCorruptError here would abort extraction for + # ALL registered variables in the product, losing even the + # variables that DO exist. Verified 2026-04-12 during sprint2i + # full backfill against 2017 commissioning data. + log.warning( + "%s missing variable %s — skipping (file may predate this variable)", + s3_key, + var_name, + ) + continue + var = ds[var_name] + var_attrs = dict(var.attrs) + + # Dim shape check — matches the projection branch. + if pv.is_3d_profile: + if expected_3d is None: + raise GoesDataCorruptError( + f"{product}/{var_name} is_3d_profile but file uses a " + f"lat/lon projection (no supported 3D layout)" + ) + if tuple(var.dims) != expected_3d: + raise GoesDataCorruptError( + f"{product}/{var_name} expected dims {expected_3d} but got {var.dims}" + ) + else: + if tuple(var.dims) != expected_2d: + raise GoesDataCorruptError( + f"{product}/{var_name} expected dims {expected_2d} but got {var.dims}" + ) + + file_units = str(var_attrs.get("units", "")) + # P2-c (D5 annotate-never-drop): a units mismatch is RECORDED as a + # qc_status="suspect" row and the loop CONTINUES — it never aborts the + # scan and never drops the variable. The loud signal is preserved as + # data (suspect), not raised away. The 2i code raised UnitsContractError + # here, which aborted the whole scan and lost every remaining variable. + units_suspect = file_units != pv.units_expected + if units_suspect: + log.warning( + "%s/%s units '%s' != registry '%s' — recording qc_status=suspect " + "and continuing (annotate-never-drop)", + product, + var_name, + file_units, + pv.units_expected, + ) + + # When the units are suspect the DQF read may itself be unreliable, but + # the 2i contract still records the pixel; mirror that — read DQF the + # same way for both clean and suspect rows. + pixel_dqf_val = _read_pixel_dqf(ds, pv, pixel_row, pixel_col, isel_kwargs=isel_kwargs) + + record_qc = "suspect" if units_suspect else "clean" + + if pv.is_3d_profile: + if "pressure" not in ds.coords and "pressure" not in ds.variables: + raise GoesDataCorruptError(f"{product} is_3d_profile but no 'pressure' coord") + pressure_coord = ds["pressure"].values + for level_idx, level_hpa in enumerate(pressure_coord): + raw_val = var.isel(**isel_kwargs).values[level_idx] + raw_arr = np.array([[raw_val]]) + scaled = _apply_scale_offset(raw_arr, var_attrs) + scaled = _apply_valid_range(scaled, var_attrs, enabled=pv.valid_range_filter) + pixel_value_scaled = float(scaled[0, 0]) + record = _build_record( + pixel_value_scaled=pixel_value_scaled, + pixel_row=pixel_row, + pixel_col=pixel_col, + pixel_dqf=pixel_dqf_val, + pv=pv, + station=station, + satellite=satellite, + product=product, + variable=var_name, + pressure_level_hpa=float(level_hpa), + scan_start_str=scan_start_str, + scan_end_str=scan_end_str, + units=file_units, + source_object_key=s3_key, + ingested_at=ingested_at, + proj=proj, + qc_status=record_qc, + ) + out_records.append(record) + else: + raw_val = var.isel(**isel_kwargs).values + raw_arr = np.array([[raw_val]]) + scaled = _apply_scale_offset(raw_arr, var_attrs) + scaled = _apply_valid_range(scaled, var_attrs, enabled=pv.valid_range_filter) + pixel_value_scaled = float(scaled[0, 0]) + record = _build_record( + pixel_value_scaled=pixel_value_scaled, + pixel_row=pixel_row, + pixel_col=pixel_col, + pixel_dqf=pixel_dqf_val, + pv=pv, + station=station, + satellite=satellite, + product=product, + variable=var_name, + pressure_level_hpa=None, + scan_start_str=scan_start_str, + scan_end_str=scan_end_str, + units=file_units, + source_object_key=s3_key, + ingested_at=ingested_at, + proj=proj, + qc_status=record_qc, + ) + out_records.append(record) + + return out_records diff --git a/packages/weather/src/mostlyright/weather/_fetchers/_goes_s3.py b/packages/weather/src/mostlyright/weather/_fetchers/_goes_s3.py new file mode 100644 index 0000000..cb502e4 --- /dev/null +++ b/packages/weather/src/mostlyright/weather/_fetchers/_goes_s3.py @@ -0,0 +1,518 @@ +"""GOES-16/19 ABI L2 whole-file S3/GCS transport (Phase 25 Wave 2). + +Isolates the network I/O — listing, rate-limiting, thread-local filesystems, +and the per-file open — from the pure-CPU extraction engine in +``_goes_extract`` (Wave 1, module-imported below). This split mirrors the 2i +monorepo's ``_get_s3_*`` / ``list_product_keys`` / ``extract_pixel`` helpers. + +KEY DIVERGENCE FROM NWP (D3 + 2i battle-plan): reads are WHOLE-FILE, and the +whole-file read is a SINGLE FULL-OBJECT read into memory — NOT a lazy +``fs.open(...)`` handed to xarray. The 2i source did +``with fs.open(uri,"rb") as f: xr.open_dataset(f, ...)``, which makes h5netcdf +issue per-chunk range GETs against the s3fs/gcsfs file object; the 2i +battle-plan measured that as the wrong primitive (a gcsfs per-range SSL +re-handshake of ~4-5s/file, serializing the pool to ~0.75 files/sec), and D3 +separately documents the lazy/byte-range single-pixel path as 4x slower than a +full read on a 37 MB DSRF file (HDF5 b-tree metadata walk dominates). So +``_read_full_object`` reads the ENTIRE object in one shot via ``fs.cat_file`` +(s3fs AND gcsfs both expose it) and ``xr.open_dataset`` opens from an in-memory +``io.BytesIO``. NO byte-range, NO ``_nwp_idx`` import, NO lazy fs object handed +to xarray — on EITHER mirror. + +D9 (AWS/GCP mirror selector, SAT-25-10): ``mirror`` is a closed enum +``{"aws", "gcp"}`` (default ``"aws"``) that selects ONLY the filesystem + +bucket + listing client. ``mirror`` is TRANSPORT ONLY — it is not source +identity and not a schema column; the same NOAA GOES product comes from either +mirror. The single-full-object read primitive, the per-product size cap, and +the ``grid_shape_expected`` shape validation are SHARED across mirrors +(``_open_and_extract``); only ``_get_fs(mirror)`` + bucket selection differ. + +Resource limits are ENFORCED, not asserted (P2-d): ``list_product_keys`` +captures each object's ``Size``; a per-product size cap rejects over-cap +objects BEFORE the full-object read; and ``_validate_dataset_shape`` checks the +dataset's variable shapes against the registry BEFORE any pixel read. +""" + +from __future__ import annotations + +import io +import logging +import threading +import time +from datetime import date +from typing import TYPE_CHECKING, Any + +import boto3 +import botocore +import botocore.config +import s3fs +import xarray as xr +from botocore.exceptions import BotoCoreError, ClientError, EndpointConnectionError +from mostlyright.core.exceptions import GoesDataCorruptError, GoesS3Error +from mostlyright.weather._fetchers._goes_extract import ( + PRODUCTS, + _extract_from_dataset, +) + +if TYPE_CHECKING: + from mostlyright._internal._stations import StationInfo + +log = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +GOES_REGION = "us-east-1" + +# D9 mirror provider switch. ``mirror`` selects ONLY the transport +# (filesystem + bucket + listing client) — it is NOT source identity. +_MIRRORS: frozenset[str] = frozenset({"aws", "gcp"}) + +# Per-mirror bucket names, keyed by satellite. AWS = anonymous NOAA NODD +# buckets; GCP = the public-data mirror documented in the 2i battle-plan. +_BUCKETS: dict[str, dict[str, str]] = { + "aws": {"goes16": "noaa-goes16", "goes19": "noaa-goes19"}, + "gcp": { + "goes16": "gcp-public-data-goes-16", + "goes19": "gcp-public-data-goes-19", + }, +} + +# available_since clamps (2i lines 63-76). Applied identically on both mirrors — +# the buckets simply have no objects before these dates. +_AVAILABLE_SINCE: dict[str, date] = { + "goes16": date(2017, 5, 24), + "goes19": date(2024, 11, 15), +} + +# Conservative top-level S3/GCS call-rate cap (2i ``_GOES_S3_RATE_HZ``). The +# empirical concurrency probe (25-05 D10) may later derive a tuned value. +_GOES_S3_RATE_HZ = 20.0 + +_MAX_S3_RETRIES = 3 +_S3_BACKOFF_BASE_S = 1.0 + +_S3_RETRY_ERRORS: tuple[type[BaseException], ...] = ( + EndpointConnectionError, + BotoCoreError, +) +_S3_FAIL_FAST_CODES = frozenset({"404", "NoSuchKey", "NoSuchBucket", "AccessDenied"}) + +# Per-product size cap (P2-d). Rejects an over-cap object BEFORE the full-object +# read so the in-memory BytesIO buffer is bounded. CONUS files run ~0.3-1.5 MB; +# DSRF full-disk runs ~50 MB. Caps are generous over the documented sizes. +# Default cap (CONUS-class) applies to any product not listed explicitly. +_DEFAULT_SIZE_CAP_BYTES = 2 * 1024 * 1024 # ~2 MB +_SIZE_CAP_BYTES: dict[str, int] = { + p: (64 * 1024 * 1024 if p == "ABI-L2-DSRF" else _DEFAULT_SIZE_CAP_BYTES) for (p, _v) in PRODUCTS +} + +# Accepted dataset shapes for the goes16-coarse vs goes19-full-disk DSRF split +# (D6 §A.6). The registry carries the goes19 full-disk ABI shape (5424x5424); +# goes16 DSRF ships on a coarse 326x326 lat/lon grid (see _goes_extract lat/lon +# projection branch). This split appears on the GCS mirror too. +_DSRF_GOES16_COARSE_SHAPE: tuple[int, int] = (326, 326) + + +# --------------------------------------------------------------------------- +# Rate limiter (identical to iem_mos._RateLimiter / 2i 475-492) +# --------------------------------------------------------------------------- +class _RateLimiter: + """Thread-safe rate limiter enforcing a minimum interval between calls.""" + + def __init__(self, min_interval: float) -> None: + self._min_interval = min_interval + self._lock = threading.Lock() + self._last: float = 0.0 + + def acquire(self) -> None: + with self._lock: + now = time.monotonic() + wait = self._min_interval - (now - self._last) + if wait > 0: + time.sleep(wait) + self._last = time.monotonic() + + +_DEFAULT_LIMITER = _RateLimiter(1.0 / _GOES_S3_RATE_HZ) + + +# --------------------------------------------------------------------------- +# Filesystem / client construction (thread-safe singletons, thread-local fs) +# --------------------------------------------------------------------------- +_s3_client: Any = None +_s3_client_lock = threading.Lock() +_thread_local = threading.local() + + +def _get_s3_client() -> Any: + """Return the process-wide boto3 UNSIGNED S3 listing client (2i 503-516).""" + global _s3_client + if _s3_client is None: + with _s3_client_lock: + if _s3_client is None: + _s3_client = boto3.client( + "s3", + region_name=GOES_REGION, + config=botocore.config.Config( + signature_version=botocore.UNSIGNED, + retries={"max_attempts": 5, "mode": "adaptive"}, + ), + ) + return _s3_client + + +def _get_s3fs() -> s3fs.S3FileSystem: + """Return a thread-local anonymous s3fs filesystem (2i 519-525).""" + if not hasattr(_thread_local, "fs_aws"): + _thread_local.fs_aws = s3fs.S3FileSystem(anon=True, default_fill_cache=False) + return _thread_local.fs_aws + + +def _get_gcsfs() -> Any: + """Return a thread-local anonymous gcsfs filesystem (D9). + + ``gcsfs`` is lazily imported (it lives in the ``[satellite]`` extra) so the + module imports cleanly on the AWS-only path without the GCP dependency. + """ + if not hasattr(_thread_local, "fs_gcp"): + import gcsfs # lazy — declared in the [satellite] extra + + _thread_local.fs_gcp = gcsfs.GCSFileSystem(token="anon") + return _thread_local.fs_gcp + + +def _check_mirror(mirror: str) -> None: + if mirror not in _MIRRORS: + raise ValueError( + f"mirror must be one of {sorted(_MIRRORS)} (transport-only selector); got {mirror!r}" + ) + + +def _get_fs(mirror: str) -> Any: + """Return the read filesystem for ``mirror`` (D9 provider switch).""" + _check_mirror(mirror) + if mirror == "aws": + return _get_s3fs() + return _get_gcsfs() + + +def _get_buckets(mirror: str, satellite: str) -> str: + """Return the bucket name for ``(mirror, satellite)`` (D9 provider switch).""" + _check_mirror(mirror) + try: + return _BUCKETS[mirror][satellite] + except KeyError as exc: + raise ValueError( + f"unknown satellite {satellite!r} for mirror {mirror!r}; " + f"expected one of {sorted(_BUCKETS[mirror])}" + ) from exc + + +# --------------------------------------------------------------------------- +# Listing — captures each object's Size (P2-d) + available_since clamp (D9) +# --------------------------------------------------------------------------- +def list_product_keys( + satellite: str, + product: str, + day: date, + utc_hours: list[int], + *, + mirror: str = "aws", + rate_limiter: _RateLimiter = _DEFAULT_LIMITER, +) -> list[tuple[str, int]]: + """List ``.nc`` ``(key, size)`` pairs for a product/day/hours on ``mirror``. + + Captures each object's ``Size`` from the listing so the size-cap guard can + reject over-cap objects BEFORE the full-object read (P2-d). Applies the + ``available_since`` clamp identically on both mirrors: a ``day`` before the + satellite's first-light date returns ``[]`` without any I/O. The AWS branch + pages the boto3 UNSIGNED ``list_objects_v2`` paginator; the GCP branch lists + via gcsfs ``.ls(detail=True)``. Retries on transient errors, fails fast on + permanent 4xx. + """ + _check_mirror(mirror) + available_since = _AVAILABLE_SINCE.get(satellite) + if available_since is not None and day < available_since: + return [] + + bucket = _get_buckets(mirror, satellite) + doy = day.timetuple().tm_yday + if mirror == "aws": + return _list_aws(bucket, product, day.year, doy, utc_hours, rate_limiter) + return _list_gcp(bucket, product, day.year, doy, utc_hours, rate_limiter) + + +def _list_aws( + bucket: str, + product: str, + year: int, + doy: int, + utc_hours: list[int], + rate_limiter: _RateLimiter, +) -> list[tuple[str, int]]: + s3 = _get_s3_client() + out: list[tuple[str, int]] = [] + for hour in sorted(set(utc_hours)): + prefix = f"{product}/{year}/{doy:03d}/{hour:02d}/" + for attempt in range(_MAX_S3_RETRIES): + try: + rate_limiter.acquire() + paginator = s3.get_paginator("list_objects_v2") + for page in paginator.paginate(Bucket=bucket, Prefix=prefix): + for obj in page.get("Contents", []): + key = obj["Key"] + if key.endswith(".nc"): + out.append((key, int(obj.get("Size", 0)))) + break + except ClientError as exc: + code = exc.response.get("Error", {}).get("Code", "") + if code in _S3_FAIL_FAST_CODES: + raise GoesS3Error( + f"permanent S3 error listing {bucket}/{prefix}: {code}" + ) from exc + if attempt == _MAX_S3_RETRIES - 1: + raise GoesS3Error( + f"S3 listing failed after {_MAX_S3_RETRIES} retries: {bucket}/{prefix}" + ) from exc + time.sleep(_S3_BACKOFF_BASE_S * (2**attempt)) + except _S3_RETRY_ERRORS as exc: + if attempt == _MAX_S3_RETRIES - 1: + raise GoesS3Error( + f"S3 listing failed after {_MAX_S3_RETRIES} retries: {bucket}/{prefix}" + ) from exc + time.sleep(_S3_BACKOFF_BASE_S * (2**attempt)) + return sorted(out) + + +def _list_gcp( + bucket: str, + product: str, + year: int, + doy: int, + utc_hours: list[int], + rate_limiter: _RateLimiter, +) -> list[tuple[str, int]]: + fs = _get_fs("gcp") + out: list[tuple[str, int]] = [] + for hour in sorted(set(utc_hours)): + prefix = f"{bucket}/{product}/{year}/{doy:03d}/{hour:02d}/" + for attempt in range(_MAX_S3_RETRIES): + try: + rate_limiter.acquire() + entries = fs.ls(prefix, detail=True) + for entry in entries: + name = entry["name"] + if name.endswith(".nc"): + # gcsfs prefixes ``name`` with the bucket; strip it so + # the returned key matches the AWS branch's bucket- + # relative form. + rel = name[len(bucket) + 1 :] if name.startswith(bucket + "/") else name + out.append((rel, int(entry.get("size", 0)))) + break + except FileNotFoundError: + # An empty hour-prefix is a normal gap, not an error. + break + except OSError as exc: + if attempt == _MAX_S3_RETRIES - 1: + raise GoesS3Error( + f"GCS listing failed after {_MAX_S3_RETRIES} retries: {prefix}" + ) from exc + time.sleep(_S3_BACKOFF_BASE_S * (2**attempt)) + return sorted(out) + + +# --------------------------------------------------------------------------- +# Size cap + shape validation gates (P2-d, SHARED across mirrors) +# --------------------------------------------------------------------------- +def _reject_oversize(key: str, size: int, product: str) -> None: + """Raise if ``size`` exceeds the per-product cap (pre-read, P2-d).""" + cap = _SIZE_CAP_BYTES.get(product, _DEFAULT_SIZE_CAP_BYTES) + if size > cap: + raise GoesDataCorruptError( + f"{key} listed size {size} bytes exceeds the {product} cap of " + f"{cap} bytes — rejected before read (P2-d resource limit)" + ) + + +def _validate_dataset_shape(ds: Any, product: str, satellite: str) -> None: + """Validate variable dims against the registry BEFORE any pixel read (P2-d). + + Honors the goes16-coarse vs goes19-full-disk DSRF split (D6 §A.6): the + registry carries the goes19 full-disk ABI shape, while goes16 DSRF ships a + coarse 326x326 lat/lon grid. Raises :class:`GoesDataCorruptError` on a shape + that matches NEITHER the registry expectation NOR the satellite's accepted + DSRF variant. SHARED across mirrors. + """ + var_entries = [pv for (p, _v), pv in PRODUCTS.items() if p == product] + if not var_entries: + raise GoesDataCorruptError(f"{product} has no registered variables in PRODUCTS") + for pv in var_entries: + var_name = pv.variable + if var_name not in ds.variables: + # Variable absent in this file (e.g. older era). Shape validation is + # per present-variable; absence is handled downstream by the + # extractor's skip-missing-variable path. + continue + actual = tuple(int(n) for n in ds[var_name].shape) + expected = tuple(int(n) for n in pv.grid_shape_expected) + + if pv.is_3d_profile: + # P2-3: registered 3D profile products (ABI-L2-LVMPC / ABI-L2-LVTPC) + # carry a TRAILING pressure axis (layout ``(y, x, pressure)``) — + # matching the byte-faithful extractor (``expected_3d = ("y", "x", + # "pressure")``) and the real LVMPC/LVTPC files — so the data + # variable is 3D ``(lat/y, lon/x, pressure)`` while + # ``grid_shape_expected`` is the 2D spatial grid. Validate ONLY the + # LEADING two (spatial) dims against the registry — the extractor's + # 3D-profile loop emits one row per pressure level downstream. A + # genuine profile file passes; a wrong spatial grid (or a non-3D + # shape) still fails loudly here. + spatial = actual[:2] + if len(actual) != 3 or spatial != expected: + raise GoesDataCorruptError( + f"{product}/{var_name} on {satellite}: 3D-profile shape {actual} " + f"does not have a trailing pressure axis (layout (y, x, pressure)) " + f"over the expected spatial grid {expected} — rejected before pixel read" + ) + continue + + accepted = {expected} + if product == "ABI-L2-DSRF": + # goes16 publishes DSRF on the coarse lat/lon grid; goes19 on the + # full-disk ABI grid. Accept ONLY the variant matching this file's + # satellite so a swapped grid still fails loudly. + accepted = {_DSRF_GOES16_COARSE_SHAPE} if satellite == "goes16" else {expected} + if actual not in accepted: + raise GoesDataCorruptError( + f"{product}/{var_name} on {satellite}: shape {actual} does not " + f"match expected {sorted(accepted)} — rejected before pixel read" + ) + + +# --------------------------------------------------------------------------- +# Single full-object read — the D3 primitive (NOT lazy fs.open) +# --------------------------------------------------------------------------- +def _read_full_object(fs: Any, uri: str) -> bytes: + """Read the ENTIRE object at ``uri`` in ONE shot and return its bytes. + + Prefers ``fs.cat_file(uri)`` (s3fs AND gcsfs both expose it); falls back to + ``with fs.open(uri, "rb") as f: f.read()`` to force a single full read. This + REPLACES the 2i lazy primitive (``with fs.open(...) as f: xr.open_dataset(f, + ...)``) so h5netcdf never issues per-chunk range GETs against the fs. NEVER + returns or keeps the lazy file object. SHARED across mirrors. + """ + cat_file = getattr(fs, "cat_file", None) + if callable(cat_file): + return cat_file(uri) + with fs.open(uri, "rb") as f: + return f.read() + + +def _open_and_extract( + fs: Any, + uri: str, + *, + product: str, + satellite: str, + station: StationInfo, + size: int, + ingested_at: str | None, +) -> list[dict[str, Any]]: + """Shared per-file pipeline (D9 factoring — mirror-agnostic). + + Order: reject-oversize (pre-read) -> single full-object read into BytesIO -> + ``xr.open_dataset(io.BytesIO(...), engine="h5netcdf", mask_and_scale=False, + decode_times=False)`` -> validate dataset shape (pre-pixel-read) -> delegate + to ``_extract_from_dataset``. The ONLY mirror-specific code outside this + helper is ``fs = _get_fs(mirror)`` + bucket selection in :func:`extract_pixel`. + """ + _reject_oversize(uri, size, product) + data = _read_full_object(fs, uri) + s3_key = uri.split("/", 3)[-1] if uri.startswith(("s3://", "gs://")) else uri + var_entries = [pv for (p, _v), pv in PRODUCTS.items() if p == product] + ds = xr.open_dataset( + io.BytesIO(data), + engine="h5netcdf", + mask_and_scale=False, + decode_times=False, + ) + _validate_dataset_shape(ds, product, satellite) + return _extract_from_dataset( + ds, + s3_key=s3_key, + product=product, + station=station, + satellite=satellite, + ingested_at=ingested_at, + var_entries=var_entries, + ) + + +# --------------------------------------------------------------------------- +# Public per-file extraction (provider-switched, retry-wrapped) +# --------------------------------------------------------------------------- +def extract_pixel( + s3_key: str, + bucket: str, + product: str, + station: StationInfo, + *, + satellite: str, + size: int, + ingested_at: str | None = None, + mirror: str = "aws", + rate_limiter: _RateLimiter = _DEFAULT_LIMITER, +) -> list[dict[str, Any]]: + """Extract the single station pixel for every registered variable of + ``product`` from ``s3_key`` on ``mirror``. + + The ONLY mirror-specific code is ``fs = _get_fs(mirror)`` + the ``s3://`` vs + ``gs://`` URI scheme; everything else (size cap, single full-object read, + shape validation, delegation) runs through the SHARED ``_open_and_extract``. + ``size`` is the listed object size from :func:`list_product_keys`, used by + the pre-read size cap. Retries on transient S3/GCS errors; re-raises + extraction errors unchanged. + """ + _check_mirror(mirror) + var_entries = [pv for (p, _v), pv in PRODUCTS.items() if p == product] + if not var_entries: + from mostlyright.core.exceptions import ProductNotRegisteredError + + raise ProductNotRegisteredError( + f"{product} has no registered variables in PRODUCTS", + ) + + scheme = "s3" if mirror == "aws" else "gs" + uri = f"{scheme}://{bucket}/{s3_key}" + + for attempt in range(_MAX_S3_RETRIES): + try: + rate_limiter.acquire() + fs = _get_fs(mirror) + return _open_and_extract( + fs, + uri, + product=product, + satellite=satellite, + station=station, + size=size, + ingested_at=ingested_at, + ) + except GoesDataCorruptError: + raise + except (ClientError, *_S3_RETRY_ERRORS, OSError) as exc: + if attempt == _MAX_S3_RETRIES - 1: + raise GoesS3Error( + f"extraction failed after {_MAX_S3_RETRIES} retries: {s3_key}" + ) from exc + time.sleep(_S3_BACKOFF_BASE_S * (2**attempt)) + + raise GoesS3Error(f"extract loop exited unexpectedly for {s3_key}") + + +__all__ = [ + "extract_pixel", + "list_product_keys", +] diff --git a/packages/weather/src/mostlyright/weather/cache.py b/packages/weather/src/mostlyright/weather/cache.py index ce25ba9..317c49a 100644 --- a/packages/weather/src/mostlyright/weather/cache.py +++ b/packages/weather/src/mostlyright/weather/cache.py @@ -260,6 +260,25 @@ def _atomic_write(path: Path, table: pa.Table) -> None: 4. If the rename fails, the lock context manager will still release the lock; the tmp file is left behind for a human to inspect. """ + # Lock sidecar is co-located with the destination — `filelock` creates it + # if missing. Use a per-path lock so writes to different stations/months + # parallelize. The 30s timeout is generous enough for a multi-MB parquet + # write under load, short enough to surface a deadlock. + lock = FileLock(str(path) + ".lock", timeout=LOCK_TIMEOUT_SECONDS) + with lock: + _write_table_unlocked(path, table) + + +def _write_table_unlocked(path: Path, table: pa.Table) -> None: + """Write ``table`` to ``path`` via tmp + ``os.replace``, WITHOUT a lock. + + Caller MUST already hold ``FileLock(str(path) + '.lock')``. This is the + inner write half of :func:`_atomic_write`, factored out so read-modify-write + callers (e.g. :func:`write_satellite_cache`) can serialize their entire + read→merge→write sequence under a single lock acquisition rather than + only the write half (which would permit a lost-update on concurrent + same-partition writers). + """ path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(".tmp") # Phase 18 PREC-04: merge existing schema metadata with the version key @@ -271,16 +290,10 @@ def _atomic_write(path: Path, table: pa.Table) -> None: existing_md = dict(table.schema.metadata or {}) existing_md[_CACHE_SCHEMA_VERSION_KEY] = _CACHE_SCHEMA_VERSION.encode("utf-8") table = table.replace_schema_metadata(existing_md) - # Lock sidecar is co-located with the destination — `filelock` creates it - # if missing. Use a per-path lock so writes to different stations/months - # parallelize. The 30s timeout is generous enough for a multi-MB parquet - # write under load, short enough to surface a deadlock. - lock = FileLock(str(path) + ".lock", timeout=LOCK_TIMEOUT_SECONDS) - with lock: - pq.write_table(table, tmp, version="2.6", coerce_timestamps="us") - # `os.replace` is atomic across both POSIX and Windows (unlike - # `os.rename` on Windows which can fail if the destination exists). - os.replace(tmp, path) + pq.write_table(table, tmp, version="2.6", coerce_timestamps="us") + # `os.replace` is atomic across both POSIX and Windows (unlike + # `os.rename` on Windows which can fail if the destination exists). + os.replace(tmp, path) # --------------------------------------------------------------------------- @@ -628,6 +641,217 @@ def invalidate_forecast( return False +# --------------------------------------------------------------------------- +# Phase 25 SAT-25-04 (D8): satellite cache tier +# --------------------------------------------------------------------------- +# Layout:: +# +# ~/.mostlyright/cache/v1/satellite/{satellite}/{product}/{station}/{YYYY}/{MM}.parquet +# +# Partition by ``scan_start_utc`` month. The path is MIRROR-INVARIANT (D9): the +# same NOAA GOES scan lands in the same partition whether it was fetched from +# the AWS or the GCS mirror, so there is NO mirror segment. Collapses the 2i +# staging->merge->R2 two-phase dance into one direct per-partition atomic write +# (no R2 step exists in the SDK). + +_SATELLITE_VALID_SATELLITES: frozenset[str] = frozenset({"goes16", "goes19"}) +_PATH_SEPARATOR_TOKENS: tuple[str, ...] = ("/", "\\", "..", os.sep) + + +def _assert_no_path_separator(value: str, *, field: str) -> None: + """Raise ValueError if ``value`` contains any path-separator token (P2-e).""" + for tok in _PATH_SEPARATOR_TOKENS: + if tok and tok in value: + raise ValueError(f"{field} {value!r} must not contain a path separator ({tok!r})") + + +def satellite_cache_path( + satellite: str, + product: str, + station: str, + year: int, + month: int, + *, + cache_root: Path | None = None, +) -> Path: + """Return the parquet cache path for a satellite partition (SAT-25-04, D8). + + Hardens EVERY user-controlled path segment (P2-e — the forecast analog + validates only ``station``): + + 1. ``station`` via :func:`validate_icao_for_path` (4-letter ICAO). + 2. ``satellite`` must be the literal enum ``{goes16, goes19}`` and contain + no path separator. + 3. ``product`` must be a known registry product (``_KNOWN_PRODUCTS`` from + ``_goes_extract``) and contain no path separator. + + Then :func:`assert_path_under` is the final path-traversal backstop. The + path has NO mirror segment (D9 — mirror is transport-only). + + ``cache_root`` (P2-1) optionally overrides the resolved cache root. The + backfill CLI threads its ``--out`` directory here so the parquet partition + lands under ``--out`` rather than the home/env cache root. When ``None`` + (every existing caller) the behavior is byte-for-byte unchanged: + :func:`_cache_root` resolves the root exactly as before. The path-traversal + backstop validates against whichever root is in effect. + """ + # Lazy import to keep the cache module parser-agnostic at import time and to + # avoid loading the [satellite]-extra-only registry on non-satellite paths. + from mostlyright.weather._fetchers._goes_extract import _KNOWN_PRODUCTS + + validate_icao_for_path(station, field="station") + + _assert_no_path_separator(satellite, field="satellite") + if satellite not in _SATELLITE_VALID_SATELLITES: + raise ValueError( + f"satellite {satellite!r} must be one of {sorted(_SATELLITE_VALID_SATELLITES)}" + ) + + _assert_no_path_separator(product, field="product") + if product not in _KNOWN_PRODUCTS: + raise ValueError( + f"product {product!r} is not a known registry product ({len(_KNOWN_PRODUCTS)} known)" + ) + + root = _cache_root() if cache_root is None else Path(cache_root) + raw = ( + root + / CACHE_VERSION + / "satellite" + / satellite + / product + / station + / f"{year:04d}" + / f"{month:02d}.parquet" + ) + assert_path_under(raw, root, field="satellite_cache_path") + return raw + + +def read_satellite_cache( + satellite: str, + product: str, + station: str, + year: int, + month: int, + *, + cache_root: Path | None = None, +) -> list[dict] | None: + """Return cached satellite rows for the partition key or ``None`` on miss. + + Returns ``None`` when the partition does not exist or (year, month) is the + current UTC month (A6 — mirrors the forecast tier; the current month may + still receive scans). ``cache_root`` (P2-1) optionally overrides the root so + a partition written under a ``--out`` directory reads back from there; when + ``None`` the default resolution is byte-for-byte unchanged. + """ + now = datetime.now(UTC) + if year == now.year and month == now.month: + return None + path = satellite_cache_path(satellite, product, station, year, month, cache_root=cache_root) + if not path.exists(): + return None + try: + table = pq.read_table(path) + except (FileNotFoundError, OSError): + return None + return table.to_pylist() + + +def write_satellite_cache( + satellite: str, + product: str, + station: str, + year: int, + month: int, + rows: list[dict], + *, + cache_root: Path | None = None, +) -> None: + """Atomically write ``rows`` to the satellite cache partition (D8). + + No-op (does NOT raise) when ``rows`` is empty or (year, month) is the + current UTC month (A6). On merge into an existing partition, the entire + read→concat→``_dedup_satellite_rows`` (first-seen-wins, mirror-invariant) + →write sequence runs under a single ``FileLock`` acquisition, so two + writers targeting the same ``(satellite, product, station, year, month)`` + partition cannot lost-update each other: the second writer reads the + first writer's already-committed rows and merges on top of them rather + than clobbering them. The inner write is the usual ``.tmp`` + + ``os.replace`` atomic swap. No staging dir, no R2. + + Unlike the overwrite-only forecast tier (``write_forecast_cache`` does not + read-modify-write), this tier is a true read-modify-write merge, so the + lock must span the read as well as the write — see ``_write_table_unlocked``. + + ``cache_root`` (P2-1) optionally overrides the cache root so the partition + lands under a caller-supplied directory (the backfill CLI threads its + ``--out`` here). When ``None`` the default ``_cache_root()`` resolution is + byte-for-byte unchanged — the forecast/observation/climate tiers and all + existing satellite-cache callers are unaffected. + """ + now = datetime.now(UTC) + if year == now.year and month == now.month: + logger.debug( + "satellite cache write: skipping current UTC month for %s/%s/%s %04d-%02d", + satellite, + product, + station, + year, + month, + ) + return + if not rows: + return + + from mostlyright._internal.merge.satellite import _dedup_satellite_rows + + path = satellite_cache_path(satellite, product, station, year, month, cache_root=cache_root) + # Serialize the WHOLE read-modify-write under one lock so concurrent + # same-partition writers cannot lost-update. The read at ``pq.read_table`` + # must be inside the same lock as the write; otherwise two writers can both + # observe the same ``existing`` and the second ``os.replace`` clobbers the + # first writer's rows. ``_write_table_unlocked`` performs the inner write + # assuming this lock is held. + lock = FileLock(str(path) + ".lock", timeout=LOCK_TIMEOUT_SECONDS) + with lock: + if path.exists(): + try: + existing = pq.read_table(path).to_pylist() + except (FileNotFoundError, OSError): + existing = [] + rows = existing + list(rows) + deduped = _dedup_satellite_rows(rows) + table = pa.Table.from_pylist(deduped) + _write_table_unlocked(path, table) + + +def invalidate_satellite( + satellite: str, + product: str, + station: str, + year: int, + month: int, + *, + cache_root: Path | None = None, +) -> bool: + """Remove a satellite cache partition if it exists; return whether removed. + + ``cache_root`` (P2-1) mirrors the read/write override so a partition written + under a ``--out`` directory can be invalidated from there; ``None`` preserves + the default resolution. + """ + path = satellite_cache_path(satellite, product, station, year, month, cache_root=cache_root) + if path.exists(): + with FileLock(str(path) + ".lock", timeout=LOCK_TIMEOUT_SECONDS): + try: + path.unlink() + return True + except FileNotFoundError: + return False + return False + + __all__ = [ "CACHE_VERSION", "DEFAULT_ROOT", @@ -640,10 +864,14 @@ def invalidate_forecast( "invalidate", "invalidate_climate", "invalidate_forecast", + "invalidate_satellite", "read_cache", "read_climate_cache", "read_forecast_cache", + "read_satellite_cache", + "satellite_cache_path", "write_cache", "write_climate_cache", "write_forecast_cache", + "write_satellite_cache", ] diff --git a/packages/weather/src/mostlyright/weather/satellite/SOURCE-LIMITS-satellite.md b/packages/weather/src/mostlyright/weather/satellite/SOURCE-LIMITS-satellite.md new file mode 100644 index 0000000..3faa8ae --- /dev/null +++ b/packages/weather/src/mostlyright/weather/satellite/SOURCE-LIMITS-satellite.md @@ -0,0 +1,50 @@ +# SOURCE-LIMITS.md — satellite (GOES ABI L2, NOAA NODD) + +**Status:** CONSERVATIVE-PENDING — no live probe has been run yet. +**Re-run command:** `python -m mostlyright.weather.satellite probe --mirror aws --out .planning/research` +**Valid until:** the first live throughput probe (D10 SAT-25-11) overwrites this +with the measured anonymous-throttle / diminishing-returns knee. + +This is the package-co-located seed the shipped concurrency constants +(`_GOES_S3_RATE_HZ`, `_DEFAULT_MAX_WORKERS` in `satellite/_backfill.py`) cite as +their provenance — mirroring how `NOMADS_CONCURRENCY_CAP` (`_nwp_archive.py:100`) +is a named constant documented as empirically derived. When the live probe runs +it writes the measured knee into BOTH a findings artifact under +`.planning/research/` AND a satellite section in `.planning/research/SOURCE-LIMITS.md` +(the canonical project artifact); the provenance-lock test then asserts the +shipped constants are floored at the recorded values. + +## Scope caveat + +These are CONSERVATIVE-PENDING values, NOT a measured saturation-curve fit. The +anonymous NOAA NODD buckets (`noaa-goes16` / `noaa-goes19` on AWS; the +`gcp-public-data-goes-*` GCS mirror) have no published per-IP throttle policy. +Until the live concurrency sweep (1/4/8/16/32 workers) runs, the rate cap and +default `max_workers` are deliberately low so a fleet backfill cannot +inadvertently over-drive an undocumented throttle. Raising either above the +recorded value is caught by the provenance-lock test; lowering it (more +conservative) is always safe. + +## GOES ABI L2 NODD whole-file reads (`s3://noaa-goes16` / GCS mirror) + +| N (concurrent) | reqs | p50_s | p95_s | p99_s | status_dist | throughput_Bps | err | +|---|---|---|---|---|---|---|---| +| N=1 | (pending) | — | — | — | {pending} | — | — | +| N=4 | (pending) | — | — | — | {pending} | — | — | +| N=8 | (pending) | — | — | — | {pending} | — | — | +| N=16 | (pending) | — | — | — | {pending} | — | — | +| N=32 | (pending) | — | — | — | {pending} | — | — | + +**Conservative-pending derived defaults** (the values the shipped constants equal): + +- derive_rate_cap: **20.0** Hz +- derive_max_workers: **8** + +## Inputs to other plans + +- **25-05 backfill constants:** `_GOES_S3_RATE_HZ = 20.0` and + `_DEFAULT_MAX_WORKERS = 8` are floored at the conservative-pending values + above. Run the probe in-region (AWS us-east-1 / GCS us-central1) to replace + these with the measured knee. The provenance-lock test + (`test_satellite_backfill.py::TestProbeDerivedConstants`) enforces + `shipped <= recorded`. diff --git a/packages/weather/src/mostlyright/weather/satellite/__init__.py b/packages/weather/src/mostlyright/weather/satellite/__init__.py new file mode 100644 index 0000000..1e06743 --- /dev/null +++ b/packages/weather/src/mostlyright/weather/satellite/__init__.py @@ -0,0 +1,701 @@ +"""Public ``satellite()`` fetcher — GOES ABI L2 single-pixel orchestration. + +Phase 25 Wave 3 (25-04). This is the single public entry point that ties the +extractor (25-02), the whole-file S3/GCS transport + cache + merge layer +(25-03), the schema source-identity contract (25-01), the leakage layer, and +the qc reducer into ONE leakage-safe DataFrame — mirroring the shape of +:func:`mostlyright.weather.forecast_nwp.forecast_nwp`. + +Body order mirrors ``forecast_nwp`` exactly: + +1. **Cheap validation FIRST** (works even WITHOUT the ``[satellite]`` extra): + ``product in _KNOWN_PRODUCTS``, the ``satellite`` enum, date ordering, and + (D9) ``mirror in {"aws", "gcp"}`` — ALL raising a loud ``ValueError`` BEFORE + any I/O and BEFORE the lazy-import guard, so a caller without the extra + still gets the correct argument error (forecast_nwp.py:710 parity). +2. ``validate_backend_kwargs(backend, return_type)`` up-front. +3. **Lazy-import guard** for ``boto3``/``s3fs``/``gcsfs``/``h5netcdf``/ + ``xarray`` -> :class:`SourceUnavailableError` with the + ``pip install mostlyrightmd-weather[satellite]`` hint. ``gcsfs`` is in the + guard (D9 — the GCP mirror needs it). +4. DSRF gating (D6): a one-time ``warnings.warn`` on the live + ``product="ABI-L2-DSRF"`` path, steering to the backfill CLI; never silently + start a multi-TB download. Mirror-agnostic. +5. Resolve stations to ICAO identity via :func:`_resolve_station_infos`. +6. Thread the validated ``mirror`` through every + ``_goes_s3.list_product_keys`` / ``extract_pixel`` call. +7. Finalize ``qc_status`` (worst-wins reduce of the + ``_validate_satellite_record`` dispositions + units-contract->suspect), + attach the leakage overlay columns (``source``/``event_time``/ + ``knowledge_time``/``retrieved_at``/``delivery``), stamp + ``df.attrs["source"] = "noaa_goes"``, filter by ``as_of`` in-process on + typed datetimes via :class:`KnowledgeView`, and wrap for backend/return_type. + +**D2 source identity (USER-LOCKED):** every row carries ``source="noaa_goes"`` +AND ``df.attrs["source"] = "noaa_goes"`` — the validator requires BOTH. The +``delivery`` enum ``{live,hosted}`` (default ``"live"``) is informational +lineage ONLY, NOT source identity. + +**D9 invariant — ``mirror`` is TRANSPORT ONLY:** ``df.attrs["source"]`` and the +per-row ``source`` column stay ``"noaa_goes"`` for BOTH mirrors; there is NO +``mirror`` row column and ``mirror`` does NOT affect the source identity. + +The heavy deps are imported INSIDE the function (D1) so the top-level module +imports cleanly without the extra. ``satellite/`` is a PACKAGE so +``python -m mostlyright.weather.satellite`` resolves a ``__main__.py`` in 25-05. +""" + +from __future__ import annotations + +import logging +import warnings +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING, Any + +# Cheap, heavy-dep-free imports only at module scope (D1 — the module must +# import without the [satellite] extra). The transport + extractor registry +# below import boto3/s3fs/xarray at THEIR module scope, so they are imported +# lazily INSIDE satellite() — NOT here. +from mostlyright._internal._stations import StationInfo +from mostlyright._internal.merge.satellite import _dedup_satellite_rows +from mostlyright.core.exceptions import ( + SourceUnavailableError, + UnitsContractError, +) + +from ._resolve import _resolve_station_infos + +if TYPE_CHECKING: + import pandas as pd + +log = logging.getLogger(__name__) + +__all__ = ["satellite"] + + +# --------------------------------------------------------------------------- +# Cheap-validation constants (heavy-dep-free). +# --------------------------------------------------------------------------- +#: Satellite identities served by the transport (D9 buckets exist for both). +_KNOWN_SATELLITES: frozenset[str] = frozenset({"goes16", "goes19"}) + +#: D9 transport mirror enum. Validated with a loud ValueError BEFORE any I/O. +_SUPPORTED_MIRRORS: frozenset[str] = frozenset({"aws", "gcp"}) + +#: D2 source identity — SHARED by live self-extraction AND the future paid +#: adapter, mirror-invariant. Stamped on df.attrs AND every per-row source. +_REGISTERED_SOURCE = "noaa_goes" + +#: D6 module-level one-time DSRF gating flag. Reset only in tests. +_DSRF_WARNED: bool = False + + +# --------------------------------------------------------------------------- +# qc_status worst-wins reducer (D5 — severity inversion, annotate-never-drop). +# --------------------------------------------------------------------------- +_QC_RANK: dict[str, int] = {"clean": 0, "flagged": 1, "suspect": 2} +_QC_BY_RANK: dict[int, str] = {0: "clean", 1: "flagged", 2: "suspect"} + +#: D5 severity inversion: an "error"-class finding (physics/structure +#: violation) is almost always an EXTRACTION bug, so the row is KEPT as +#: ``suspect`` rather than dropped; a "warning"-class finding -> ``flagged``. +_SEVERITY_TO_QC: dict[str, str] = {"error": "suspect", "warning": "flagged"} + + +def _qc_status_for_row(record: dict[str, Any]) -> str: + """Finalize ``qc_status`` for one row (worst-wins, severity-inverted). + + Reduces TWO inputs worst-wins: + + - the extractor's per-row verdict already on ``record["qc_status"]`` + (P2-c: a per-variable units mismatch arrives marked ``"suspect"``); and + - the ``_validate_satellite_record`` dispositions mapped through the D5 + severity inversion (``error`` -> ``suspect``, ``warning`` -> ``flagged``). + + ``pixel_value=None`` on a NetCDF ``_FillValue`` is a CLEAN data condition + (``_validate_satellite_record`` emits no finding for it), so it stays + ``"clean"`` unless some OTHER finding bumps it. + """ + from mostlyright._internal.merge.satellite import _validate_satellite_record + + # Start from the extractor's verdict (defaults to "clean"). + worst = _QC_RANK.get(str(record.get("qc_status", "clean")), 0) + for finding in _validate_satellite_record(record): + mapped = _SEVERITY_TO_QC.get(finding.severity, "suspect") + worst = max(worst, _QC_RANK[mapped]) + return _QC_BY_RANK[worst] + + +# --------------------------------------------------------------------------- +# DSRF gating (D6) — one-time warning, mirror-agnostic. +# --------------------------------------------------------------------------- +def _maybe_warn_dsrf(product: str) -> None: + """Emit a one-time DSRF gating warning on the live path (D6).""" + global _DSRF_WARNED + if product != "ABI-L2-DSRF" or _DSRF_WARNED: + return + _DSRF_WARNED = True + warnings.warn( + "product='ABI-L2-DSRF' is full-disk (~50 MB/file, ~25 of the 28 TB v1 " + "corpus). The live satellite() path fetches per-scan and will NOT " + "silently start a multi-TB download. For bulk/training pulls use the " + "backfill CLI and run it in-region (near-data compute, free NODD " + "egress): python -m mostlyright.weather.satellite backfill ...", + category=UserWarning, + stacklevel=2, + ) + + +# --------------------------------------------------------------------------- +# Day enumeration (event-time window, cheap stdlib). +# --------------------------------------------------------------------------- +def _days_in_range(start: datetime, end: datetime) -> list[Any]: + """Return the inclusive list of UTC ``date``s spanned by ``[start, end]``.""" + s = start.astimezone(UTC).date() if start.tzinfo is not None else start.date() + e = end.astimezone(UTC).date() if end.tzinfo is not None else end.date() + out = [] + cur = s + one = timedelta(days=1) + while cur <= e: + out.append(cur) + cur = cur + one + return out + + +def _event_time_window(start: datetime, end: datetime) -> tuple[datetime, datetime]: + """Compute the inclusive event-time bounds ``[lo, hi]`` (tz-aware UTC). + + ``_days_in_range`` widens the FETCH to whole UTC days (a scan can only be + listed at the day grain), but the emitted rows must be filtered back to the + requested window (P2-1) — otherwise a sub-day window (e.g. ``12:00-13:00``) + leaks every scan on that date. + + Semantics (date-granular-aware, mirroring ``obs()`` inclusive-bound dates): + + - ``start``: normalized to UTC. A midnight ``start`` already IS the start + of its day, so ``lo = start``. + - ``end``: normalized to UTC. A midnight (date-granular) ``end`` means + "through the END of that day", so ``hi`` is pushed to + ``end.date()`` at ``23:59:59.999999``. An ``end`` carrying a sub-day + time is treated precisely (``hi = end``). + + Both bounds are INCLUSIVE. + """ + lo = start.astimezone(UTC) if start.tzinfo is not None else start.replace(tzinfo=UTC) + hi = end.astimezone(UTC) if end.tzinfo is not None else end.replace(tzinfo=UTC) + # A midnight end is date-granular -> extend to the end of that UTC day so + # whole-day queries keep every in-day scan (existing behavior preserved). + if (hi.hour, hi.minute, hi.second, hi.microsecond) == (0, 0, 0, 0): + hi = hi.replace(hour=23, minute=59, second=59, microsecond=999999) + return lo, hi + + +# --------------------------------------------------------------------------- +# Public fetcher +# --------------------------------------------------------------------------- +def satellite( + station: str | list[str], + satellite: str, + product: str = "ABI-L2-ACMC", + *, + variable: str | None = None, + start: datetime, + end: datetime, + qc: str = "clean", + as_of: Any = None, + mirror: str = "aws", + cache: bool = True, + max_workers: int = 8, + backend: str = "pandas", + return_type: str = "dataframe", +) -> pd.DataFrame: + """Fetch GOES ABI L2 single-pixel values for one or more stations. + + Args: + station: Single ICAO/NWS code or a list. Unknown codes are skipped + with a logged warning (partial list -> partial DataFrame). + satellite: ``"goes16"`` or ``"goes19"``. + product: ABI L2 product id (default ``"ABI-L2-ACMC"``; the cheap CONUS + cloud-mask product). ``"ABI-L2-DSRF"`` emits a one-time gating + warning (D6) on the live path. + variable: Optional single-variable filter; ``None`` keeps every + registered variable of ``product``. + start: Event-time window start (UTC, tz-aware recommended). + end: Event-time window end. ``end < start`` raises ``ValueError``. + qc: Reserved qc-filter knob (annotate-never-drop — no row is dropped). + as_of: Knowledge-time cutoff. ``TimePoint | datetime | None``. Filters + in-process on typed datetimes via :class:`KnowledgeView`; a naive + datetime is rejected loudly (NOT a lexical string snapshot, D4). + mirror: ``"aws"`` (default) or ``"gcp"`` — the D9 TRANSPORT selector. + Validated with a loud ``ValueError`` BEFORE any I/O. Mirror is + transport-only: the source identity stays ``"noaa_goes"`` and there + is NO ``mirror`` row column. + cache: Reserved cache toggle (per-partition parquet tier, 25-03). + max_workers: Reserved thread fan-out width (documented UNTUNED, D10). + backend: ``"pandas"`` (default) or ``"polars"``. + return_type: ``"dataframe"`` (default) or ``"result"``. + + Returns: + ``pd.DataFrame`` carrying the satellite rows plus the leakage overlay + columns (``source``/``event_time``/``knowledge_time``/``retrieved_at``/ + ``delivery``) and ``qc_status``, with ``df.attrs["source"]="noaa_goes"``. + + Raises: + ValueError: ``product``/``satellite``/``mirror`` not in their enums, or + ``end < start``, or a naive ``as_of`` datetime. + SourceUnavailableError: the ``[satellite]`` optional extra is absent. + """ + # --- 1. Cheap validation FIRST (no I/O, no heavy imports). ----------- + # Import the heavy-dep-free registry of known products. The PRODUCTS + # registry lives in _goes_extract whose module scope imports numpy; that + # is part of the [satellite] extra. To keep cheap validation usable WITHOUT + # the extra we validate the product against a small local set when the + # registry import fails, but the registry IS the source of truth when + # present. + known_products = _known_products() + if product not in known_products: + raise ValueError(f"product must be one of {sorted(known_products)}; got {product!r}") + if satellite not in _KNOWN_SATELLITES: + raise ValueError(f"satellite must be one of {sorted(_KNOWN_SATELLITES)}; got {satellite!r}") + if end < start: + raise ValueError( + f"end must be >= start (event-time ordering); got start={start!r}, end={end!r}" + ) + if mirror not in _SUPPORTED_MIRRORS: + # D9 / forecast_nwp.py:710 parity — loud, pre-I/O, lists the enum. + raise ValueError( + f"mirror must be one of {sorted(_SUPPORTED_MIRRORS)} (transport-only " + f"selector); got {mirror!r}" + ) + + # --- 2. Backend kwargs validated up-front (before any network). ------ + from mostlyright.core._backend_dispatch import validate_backend_kwargs + + validate_backend_kwargs(backend, return_type) # type: ignore[arg-type] + + # --- 3. Lazy-import guard (covers gcsfs, D9) -> SourceUnavailableError. - + try: + import boto3 # noqa: F401 + import gcsfs # noqa: F401 — D9 GCP mirror dep + import h5netcdf # noqa: F401 + import s3fs # noqa: F401 + import xarray # noqa: F401 + except ImportError as exc: + raise SourceUnavailableError( + "GOES satellite extraction requires the [satellite] optional " + "extra. Install with: pip install mostlyrightmd-weather[satellite]", + source=f"satellite.{satellite}", + retryable=False, + underlying=str(exc), + ) from None + + import pandas as pd + + # --- 4. DSRF gating (D6, mirror-agnostic, one-time). ----------------- + _maybe_warn_dsrf(product) + + # --- 5. Resolve stations to ICAO identity (D2 pre-write reconcile). -- + station_list: list[str] = [station] if isinstance(station, str) else list(station) + infos = _resolve_station_infos(station_list) + + # Normalize as_of to a TimePoint for the leakage layer (typed, never a + # lexical string snapshot — D4). A naive datetime fails loudly inside + # TimePoint's coercion. + as_of_tp = _coerce_as_of(as_of) + + retrieved_at = datetime.now(UTC) + days = _days_in_range(start, end) + # P2-1: the FETCH is widened to whole UTC days (scans list at the day grain), + # but emitted rows are filtered back to the requested event-time window so a + # sub-day window does not leak every scan on the boundary days. + window_lo, window_hi = _event_time_window(start, end) + all_hours = list(range(24)) + + rows: list[dict[str, Any]] = [] + if infos: + for info in infos: + for day in days: + # --- 6. Thread the validated mirror to the transport. ---- + records = _fetch_station_day( + info=info, + satellite=satellite, + product=product, + day=day, + utc_hours=all_hours, + mirror=mirror, + ) + for rec in records: + if variable is not None and rec.get("variable") != variable: + continue + row = _finalize_row(rec, retrieved_at=retrieved_at) + # P2-1: drop rows whose event-time (scan_start_utc) is OUTSIDE + # [window_lo, window_hi]. A degenerate suspect SENTINEL row + # (empty scan_start -> event_time None) is annotate-never-drop + # (D5): keep it so the boundary units-contract signal ships. + event_dt = row.get("event_time") + if event_dt is not None and not (window_lo <= event_dt <= window_hi): + continue + rows.append(row) + + # --- 7a. Dedup first-seen-wins on the live path (P2-a). -------------- + # NOAA reprocesses a scan with a new creation-time (c<...>) token but an + # IDENTICAL scan_start (s<...>) — both keys list + extract, both emit a row + # with the same 6-tuple dedup key. Collapse them here BEFORE assembling the + # DataFrame, mirroring how cache.write_satellite_cache dedups, so the live + # path honors the "deduped first-seen-wins" contract (docs/satellite.md) and + # never silently double-counts a scan when a quant unions live pulls. + rows = _dedup_satellite_rows(rows) + + df = _assemble_dataframe(rows, pd=pd, retrieved_at=retrieved_at) + + # --- 7b. as_of filtering — in-process, typed (D4). ------------------- + if as_of_tp is not None and not df.empty: + from mostlyright.core.temporal.knowledge_view import KnowledgeView + + df = KnowledgeView(df, as_of_tp).dataframe() + + # --- 7c. Schema source-identity validation (P2-b, D2). --------------- + # Run the registered schema.satellite.v1 validator so the source-identity + # invariant (df.attrs["source"] AND the per-row source column both + # "noaa_goes") is actually ENFORCED on the live path — not merely stamped. + _validate_against_schema(df) + + return _maybe_wrap_satellite(df, backend=backend, return_type=return_type) + + +# --------------------------------------------------------------------------- +# Internals +# --------------------------------------------------------------------------- +def _known_products() -> frozenset[str]: + """Return the set of known ABI L2 products (registry-backed when present). + + The PRODUCTS registry is the source of truth; it lives in ``_goes_extract`` + whose module scope imports numpy ([satellite] extra). Cheap validation runs + BEFORE the lazy-guard, so we try the registry import and fall back to a + minimal hard-coded set if the extra is absent (so an unknown product still + raises the correct ValueError without the extra). + """ + try: + from mostlyright.weather._fetchers._goes_extract import _KNOWN_PRODUCTS + + return frozenset(_KNOWN_PRODUCTS) + except ImportError: # pragma: no cover — extra present in CI + return frozenset( + { + "ABI-L2-ACMC", + "ABI-L2-ACHAC", + "ABI-L2-AODC", + "ABI-L2-CTPC", + "ABI-L2-DSIC", + "ABI-L2-DSRF", + "ABI-L2-LSTC", + "ABI-L2-LVMPC", + "ABI-L2-LVTPC", + "ABI-L2-TPWC", + } + ) + + +def _coerce_as_of(as_of: Any) -> Any: + """Coerce ``as_of`` to a :class:`TimePoint` (or None). Naive -> loud. + + Accepts ``TimePoint``, ``datetime``, or ``None``. A naive datetime is + rejected by TimePoint's coercion (raises ``ValueError``), satisfying the + leakage layer's tz-aware precondition. + """ + if as_of is None: + return None + from mostlyright.core.temporal.timepoint import TimePoint + + if isinstance(as_of, TimePoint): + return as_of + return TimePoint(as_of) + + +def _fetch_station_day( + *, + info: StationInfo, + satellite: str, + product: str, + day: Any, + utc_hours: list[int], + mirror: str, +) -> list[dict[str, Any]]: + """List keys + extract pixels for one (station, day), threading ``mirror``. + + Catches a propagating :class:`UnitsContractError` from the extractor + (P2-c defensive boundary) and converts it to a single ``qc_status=suspect`` + row rather than letting it escape ``satellite()``. + """ + _list_product_keys = _transport("list_product_keys") + _extract_pixel = _transport("extract_pixel") + keys = _list_product_keys( + satellite, + product, + day, + utc_hours, + mirror=mirror, + ) + bucket = _bucket_for(mirror, satellite) + out: list[dict[str, Any]] = [] + for s3_key, size in keys: + try: + recs = _extract_pixel( + s3_key, + bucket, + product, + info, + satellite=satellite, + size=size, + mirror=mirror, + ) + out.extend(recs) + except UnitsContractError as exc: + # P2-c defensive: a genuinely-unrecoverable units mismatch that + # still propagates is annotated (not dropped, not raised out). + out.append( + _suspect_units_row( + info=info, + satellite=satellite, + product=product, + s3_key=s3_key, + detail=str(exc), + ) + ) + return out + + +def _bucket_for(mirror: str, satellite: str) -> str: + """Return the transport bucket for ``(mirror, satellite)`` (D9).""" + from mostlyright.weather._fetchers._goes_s3 import _get_buckets + + return _get_buckets(mirror, satellite) + + +def _suspect_units_row( + *, + info: StationInfo, + satellite: str, + product: str, + s3_key: str, + detail: str, +) -> dict[str, Any]: + """Build a minimal ``qc_status=suspect`` row for an unrecoverable units case.""" + return { + "station": info.icao, + "satellite": satellite, + "product": product, + "variable": "", + "pressure_level_hpa": None, + "scan_start_utc": "", + "scan_end_utc": "", + "delivery": "live", + "source_object_key": s3_key, + "ingested_at": None, + "pixel_value": None, + "pixel_dqf": None, + "pixel_row": -1, + "pixel_col": -1, + "units": "", + "station_lat": float(info.latitude), + "station_lon": float(info.longitude), + "sat_lon_used": float("nan"), + "qc_status": "suspect", + "as_of_time": None, + "_units_contract_detail": detail, + } + + +def _finalize_row(rec: dict[str, Any], *, retrieved_at: datetime) -> dict[str, Any]: + """Finalize one record: qc_status + leakage overlay + source identity. + + - ``qc_status`` worst-wins reduce (extractor verdict + validate dispositions). + - ``source`` = ``"noaa_goes"`` on EVERY row (D2 — validator requires it, + mirror-invariant per D9). + - ``event_time`` = ``scan_start_utc`` (tz-aware UTC, event-time, D4). + - ``knowledge_time`` = ``as_of_time`` where ``as_of_time = ingested_at`` + when present else the fetch ts (A3). tz-aware UTC so assert_no_leakage + accepts it. + - ``retrieved_at`` = fetch ts; ``delivery`` carried through (default + ``"live"`` — informational lineage ONLY, separate from source identity). + """ + row = dict(rec) + + # A row built by the defensive units-suspect path is already suspect; the + # reducer keeps it suspect (its empty scan times produce error findings, + # which still reduce to suspect — consistent). + row["qc_status"] = _qc_status_for_row(row) + + # D2 source identity overlay (mirror-invariant). + row["source"] = _REGISTERED_SOURCE + + # D4 leakage columns. + event_dt = _parse_utc(row.get("scan_start_utc")) + row["event_time"] = event_dt + + ingested_at = row.get("ingested_at") + knowledge_dt = _parse_utc(ingested_at) if ingested_at else retrieved_at + if knowledge_dt is None: + # Unparseable ingested_at -> fall back to the fetch ts so the row stays + # leakage-checkable rather than carrying a null knowledge_time. + knowledge_dt = retrieved_at + row["knowledge_time"] = knowledge_dt + row["as_of_time"] = knowledge_dt.strftime("%Y-%m-%dT%H:%M:%SZ") + row["retrieved_at"] = retrieved_at + + # delivery is already on the record (default "live"); keep it untouched. + row.setdefault("delivery", "live") + + # Internal helper key never ships to the DataFrame. + row.pop("_units_contract_detail", None) + return row + + +def _parse_utc(ts: Any) -> datetime | None: + """Parse a strict ``YYYY-MM-DDTHH:MM:SSZ`` string to a tz-aware UTC dt.""" + if not isinstance(ts, str) or not ts: + return None + try: + return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) + except ValueError: + return None + + +def _assemble_dataframe( + rows: list[dict[str, Any]], *, pd: Any, retrieved_at: datetime +) -> pd.DataFrame: + """Assemble the canonical satellite DataFrame + stamp df.attrs (D2 + P2-4).""" + df = pd.DataFrame(rows) + # Stamp the source-identity attr (validator reconciles against this AND the + # per-row source column). Mirror-invariant (D9). + df.attrs["source"] = _REGISTERED_SOURCE + # P2-4: stamp the REAL fetch timestamp (mirrors forecast_nwp.py:1076) so + # return_type="wrapper" uses it instead of a synthetic datetime.now(). The + # per-row retrieved_at is already set by _finalize_row; this is the + # frame-level provenance the leakage/wrapper layers read. + df.attrs["retrieved_at"] = retrieved_at + # Ensure tz-aware UTC knowledge_time even on an empty frame so + # assert_no_leakage's dtype precondition holds. + if "knowledge_time" in df.columns and len(df) > 0: + df["knowledge_time"] = pd.to_datetime(df["knowledge_time"], utc=True) + if "event_time" in df.columns and len(df) > 0: + df["event_time"] = pd.to_datetime(df["event_time"], utc=True) + return df + + +#: Live-frame columns the registered schema declares as ``timestamp_utc`` but +#: the byte-faithful records carry as RFC3339-Z STRINGS. The validation +#: projection coerces ONLY these (on a copy) so the returned frame stays string- +#: typed (parity) while the schema's source-identity + dtype checks still run. +_TIMESTAMP_COLUMNS: tuple[str, ...] = ( + "scan_start_utc", + "scan_end_utc", + "ingested_at", + "as_of_time", +) + +#: Nullable float64 columns that infer ``object`` dtype when every row is NULL +#: (e.g. ``pressure_level_hpa`` on 2D products, ``pixel_dqf`` for no-DQF +#: variables, ``pixel_value`` on a ``_FillValue`` scan). Coerced on the +#: validation copy so an all-null column still matches the schema's declared +#: ``float64`` dtype. +_NULLABLE_FLOAT_COLUMNS: tuple[str, ...] = ("pressure_level_hpa", "pixel_dqf", "pixel_value") + + +def _validate_against_schema(df: pd.DataFrame) -> None: + """Run ``validate_dataframe(df, "schema.satellite.v1")`` on a typed copy. + + The returned satellite frame keeps the byte-faithful RFC3339-Z STRING scan + times (2i parity), but ``schema.satellite.v1`` declares those columns + ``timestamp_utc``. So validation runs against a PROJECTED COPY that coerces + those columns to tz-aware UTC datetimes and drops the internal helper + column — the original frame is never mutated. The point (P2-b) is that the + validator's source-identity invariant (``df.attrs["source"]`` AND the + per-row ``source`` column both ``"noaa_goes"``) actually EXECUTES; a tampered + source raises loudly instead of silently shipping mismatched provenance. + + Defensive ``qc_status="suspect"`` SENTINEL rows (the P2-c units-contract + boundary path) are deliberately degenerate annotations — empty scan times, + ``pixel_row=-1`` — and are annotate-never-drop (D5): they MUST ship even + though they are not schema-conformant. They are therefore excluded from the + strict dtype/null validation by filtering to rows with a parseable + ``scan_start_utc`` (every genuine extracted row has one). The source overlay + is still stamped on those sentinel rows by :func:`_finalize_row`. + """ + import pandas as pd + from mostlyright.core.validator import validate_dataframe + + if df.empty: + # Nothing to validate; df.attrs["source"] is still stamped (D2) and the + # validator requires non-empty rows for the per-row source check. + return + + proj = df.copy() + proj.attrs = dict(df.attrs) + proj.drop(columns=["_units_contract_detail"], errors="ignore", inplace=True) + for col in _TIMESTAMP_COLUMNS: + if col in proj.columns: + proj[col] = pd.to_datetime(proj[col], utc=True, errors="coerce") + for col in _NULLABLE_FLOAT_COLUMNS: + if col in proj.columns: + proj[col] = pd.to_numeric(proj[col], errors="coerce").astype("float64") + + # Exclude annotate-never-drop suspect SENTINEL rows (no parseable + # scan_start) from the strict schema check; genuine extracted rows all + # carry a scan_start. + if "scan_start_utc" in proj.columns: + proj = proj[proj["scan_start_utc"].notna()] + if proj.empty: + return + + validate_dataframe(proj, "schema.satellite.v1") + + +def _maybe_wrap_satellite(df: pd.DataFrame, *, backend: str, return_type: str) -> Any: + """Backend/return_type post-processing (mirrors ``_maybe_wrap_forecast``).""" + if backend == "pandas" and return_type == "dataframe": + return df + + from mostlyright.core._backend_dispatch import wrap_result + + return wrap_result( + df, + backend=backend, # type: ignore[arg-type] + return_type=return_type, # type: ignore[arg-type] + source=str(df.attrs.get("source", _REGISTERED_SOURCE)), + retrieved_at=df.attrs.get("retrieved_at"), + schema_id="schema.satellite.v1", + ) + + +# --------------------------------------------------------------------------- +# Transport bindings (module-level names so tests can monkeypatch them). +# +# These are thin handles on the 25-03 transport. ``_goes_s3`` imports +# boto3/s3fs at ITS module scope, so eagerly importing it here would break the +# "imports cleanly without the [satellite] extra" contract (D1). So the names +# are bound LAZILY: +# +# - ``__getattr__`` binds them on first attribute access (``sat_pkg.X``) so a +# reader / monkeypatch can reach + replace them. +# - ``_transport(name)`` returns the current binding (the monkeypatched one +# when a test patched it, else the real transport bound on demand) for use +# inside the fetch loop, where bare-name global lookup would NOT trigger +# ``__getattr__``. +# --------------------------------------------------------------------------- +def _transport(name: str) -> Any: + """Return the current binding for a transport callable (monkeypatch-aware).""" + if name in globals(): + return globals()[name] + from mostlyright.weather._fetchers import _goes_s3 + + value = getattr(_goes_s3, name) + globals()[name] = value + return value + + +def __getattr__(name: str) -> Any: + """Lazily bind the transport callables (keeps top-level import dep-free).""" + if name in {"list_product_keys", "extract_pixel"}: + return _transport(name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/packages/weather/src/mostlyright/weather/satellite/__main__.py b/packages/weather/src/mostlyright/weather/satellite/__main__.py new file mode 100644 index 0000000..88be552 --- /dev/null +++ b/packages/weather/src/mostlyright/weather/satellite/__main__.py @@ -0,0 +1,183 @@ +"""argparse CLI for the GOES ABI L2 fleet tooling (Phase 25 Wave 4 / 25-05). + +``python -m mostlyright.weather.satellite ``: + + - ``backfill`` — the fleet bulk/training path (D7). Per-(satellite, year, + month) array-job-friendly slices, crash-safe resume, Thread/Process split, + and the D9 ``--mirror aws|gcp`` transport selector (default ``aws``, + validated by argparse ``choices`` so an unknown mirror is rejected BEFORE + any run). Dispatches to :func:`_backfill.bulk_backfill`. + - ``probe`` — the on-demand / live throughput probe (D10 SAT-25-11). Measures + the anonymous-throttle / diminishing-returns knee against the LIVE NOAA + buckets and writes the SOURCE-LIMITS findings artifact + satellite section + that the shipped concurrency constants cite as provenance. Dispatches to + :func:`_probe.run_probe` (the actual run hits the network; CI never invokes + it). + +Users NEVER hand-edit cache paths: every ``(satellite, product, station)`` +triple flows through the hardened ``cache.satellite_cache_path`` (via +``bulk_backfill``'s boundary validation), so a malicious ``--satellites "../"`` +/ ``--products "ABI-L2-ACMC/../.."`` is rejected before any write (P2-e). +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from ._backfill import bulk_backfill + + +def _split_csv(value: str) -> list[str]: + """Split a comma-separated CLI list, dropping empties/whitespace.""" + return [tok.strip() for tok in value.split(",") if tok.strip()] + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="python -m mostlyright.weather.satellite", + description="GOES ABI L2 satellite fleet tooling (backfill + rate-limit probe).", + ) + sub = parser.add_subparsers(dest="command", required=True) + + # ---- backfill ---------------------------------------------------------- + bf = sub.add_parser( + "backfill", + help="Fleet bulk backfill — per-(satellite,year,month) slices, resume, " + "Thread/Process split, --mirror aws|gcp.", + ) + bf.add_argument( + "--satellites", + type=_split_csv, + required=True, + help="Comma-separated satellites, e.g. goes16,goes19.", + ) + bf.add_argument( + "--products", + type=_split_csv, + required=True, + help="Comma-separated ABI L2 products, e.g. ABI-L2-ACMC.", + ) + bf.add_argument( + "--stations", + type=_split_csv, + required=True, + help="Comma-separated ICAO/NWS station codes, e.g. KNYC.", + ) + bf.add_argument("--year-start", type=int, required=True, dest="year_start") + bf.add_argument("--year-end", type=int, required=True, dest="year_end") + bf.add_argument("--out", type=Path, required=True, help="Cache root output dir.") + bf.add_argument( + "--max-workers", + type=int, + default=None, + dest="max_workers", + help="Fan-out width (default: the probe-derived _DEFAULT_MAX_WORKERS).", + ) + bf.add_argument( + "--resume", + dest="resume", + action="store_true", + default=True, + help="Skip slices already marked completed (default).", + ) + bf.add_argument( + "--no-resume", + dest="resume", + action="store_false", + help="Re-run every slice (still acquires the single-writer lock).", + ) + bf.add_argument( + "--executor", + choices=["thread", "process"], + default="thread", + help="thread = small CONUS files (default); process = DSRF full-disk decode.", + ) + bf.add_argument( + "--mirror", + choices=["aws", "gcp"], + default="aws", + help="Transport mirror (D9): aws (default, NOAA NODD) or gcp " + "(public-data mirror). Transport-only — does not change the data.", + ) + + # ---- probe ------------------------------------------------------------- + pr = sub.add_parser( + "probe", + help="Empirical rate-limit / throughput probe (D10) — measures the " + "concurrency knee against LIVE NOAA buckets; writes SOURCE-LIMITS.md.", + ) + pr.add_argument( + "--mirror", + choices=["aws", "gcp"], + default="aws", + help="Mirror to probe (default aws).", + ) + pr.add_argument( + "--out", + type=Path, + default=Path(".planning/research"), + help="Findings-artifact output dir (default .planning/research).", + ) + pr.add_argument( + "--sweep", + type=_split_csv, + default=None, + help="Comma-separated concurrency levels (default 1,4,8,16,32).", + ) + + return parser + + +def _run_backfill(args: argparse.Namespace) -> int: + kwargs: dict = { + "satellites": args.satellites, + "products": args.products, + "stations": args.stations, + "year_start": args.year_start, + "year_end": args.year_end, + "out": args.out, + "resume": args.resume, + "executor": args.executor, + "mirror": args.mirror, + } + if args.max_workers is not None: + kwargs["max_workers"] = args.max_workers + result = bulk_backfill(**kwargs) + print( + f"backfill done: {result.slices_completed} slices completed, " + f"{result.slices_skipped_resume} skipped (resume), " + f"{result.total_rows_written} rows written, " + f"{result.duration_s:.1f}s" + ) + return 0 + + +def _run_probe(args: argparse.Namespace) -> int: + from ._probe import run_probe + + sweep = tuple(int(n) for n in args.sweep) if args.sweep else (1, 4, 8, 16, 32) + result = run_probe(mirror=args.mirror, out_dir=args.out, sweep=sweep) + print( + f"probe done: derive_rate_cap={result.rate_cap_hz:.1f} Hz, " + f"derive_max_workers={result.max_workers}; " + f"findings + SOURCE-LIMITS.md written under {args.out}" + ) + return 0 + + +def main(argv: list[str] | None = None) -> int: + """Parse ``argv`` and dispatch the chosen subcommand. Returns an exit code.""" + parser = _build_parser() + args = parser.parse_args(argv) + if args.command == "backfill": + return _run_backfill(args) + if args.command == "probe": + return _run_probe(args) + parser.error(f"unknown command {args.command!r}") + return 2 # pragma: no cover — parser.error raises SystemExit + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main(sys.argv[1:])) diff --git a/packages/weather/src/mostlyright/weather/satellite/_backfill.py b/packages/weather/src/mostlyright/weather/satellite/_backfill.py new file mode 100644 index 0000000..98abb35 --- /dev/null +++ b/packages/weather/src/mostlyright/weather/satellite/_backfill.py @@ -0,0 +1,716 @@ +"""GOES ABI L2 fleet backfill orchestrator (Phase 25 Wave 4 / 25-05). + +The bulk/training path that runs LATER on a VM fleet in-region (AWS us-east-1 or +GCS us-central1 — free NODD egress). This module builds the CODE that runs on +the fleet; it does NOT run the 28 TB backfill. + +Ported + EDITED from the 2i ``backfill_goes_satellite`` (1266-1439) and +``backfill_all_goes_satellite`` (1442-1510) with the LOCKED couplings severed: + + - **D8 — direct per-partition atomic write.** The 2i two-phase + stage-then-merge-then-upload pipeline is collapsed to a single direct + ``cache.write_satellite_cache(satellite, product, station, year, month, rows)`` + per ``(satellite, product, station, YYYY, MM)`` slice. No staging dir, no + glob-merge, no object-store upload (none exists in the SDK). + - **D9 — mirror thread-through (SAT-25-10).** ``mirror`` is a closed enum + ``{"aws", "gcp"}`` (default ``"aws"``) threaded into every + ``_goes_s3.list_product_keys(..., mirror=mirror)`` / + ``extract_pixel(..., mirror=mirror)`` call. ``mirror`` is TRANSPORT ONLY — + it does NOT change the cache partition (the same NOAA GOES data lands in the + same ``{satellite}/{product}/{station}/{YYYY}/{MM}.parquet`` from either + mirror) and does NOT touch source identity. + - **ICAO identity (D2).** The 2i monorepo station import is replaced by the + SDK ``_resolve_station_infos`` (25-04, ICAO). + - **Thread/Process split (D7).** ``ThreadPool`` for small CONUS files; + ``ProcessPool`` for DSRF full-disk decode (5424x5424 HDF5 decode is + CPU-bound + GIL-serialized + behind the HDF5 global mutex). The ProcessPool + path is NEW code on top of the thread-only 2i orchestrator. + +**Crash-safe resume (D7).** A JSON progress file keyed on the FULL slice +identity ``"{satellite}_{product}_{station}_{YYYY}_{MM}" -> "completed"`` (P1-2) +with a ``.bak`` sibling, +fsync(tmp)->os.replace->fsync(parent) durability + an ``os.sync()`` barrier +BEFORE each mark, malformed-key / non-``completed``-value rejection, and a +single-writer ``O_CREAT|O_EXCL`` lockfile (PID+hostname, released in ``finally``) +that makes a double-start raise :class:`ProgressLockBusy`. + +**FIX-2 — probe-derived named constants (D10 SAT-25-11).** ``_GOES_S3_RATE_HZ`` +(the rate-limiter cap) and ``_DEFAULT_MAX_WORKERS`` (the ``bulk_backfill`` +default) are NAMED module constants whose values ARE the probe-derived +recommendation, each carrying a comment citing the SOURCE-LIMITS.md satellite +entry as provenance — EXACTLY mirroring how ``NOMADS_CONCURRENCY_CAP`` +(``_nwp_archive.py:100``) is a named constant documented as empirically derived. +Conservative pending the first live probe run; NOT a doc-only recommendation. +The provenance-lock test asserts the shipped constants are floored at the values +recorded in the SOURCE-LIMITS.md satellite entry. +""" + +from __future__ import annotations + +import contextlib +import json +import logging +import os +import re +import socket +import time +from concurrent.futures import Executor, ProcessPoolExecutor, ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from datetime import date +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from mostlyright.core.exceptions import SatelliteError + +# Transport (25-03). These bare module-level names are what tests monkeypatch +# (``mock.patch.object(_backfill, "list_product_keys")``); the bulk/slice code +# below references them by bare name so a patched binding is honored. +from mostlyright.weather._fetchers._goes_s3 import ( + _AVAILABLE_SINCE, + extract_pixel, + list_product_keys, +) +from mostlyright.weather.cache import write_satellite_cache + +from ._resolve import _resolve_station_infos + +if TYPE_CHECKING: + from mostlyright._internal._stations import StationInfo + + #: Fully-picklable per-slice payload submitted to the pool worker + #: (P1-1). Carries the run-wide params (out/mirror/max_workers) by VALUE so + #: the ``executor="process"`` path never needs to pickle a closure. + _SliceItem = tuple[StationInfo, str, str, int, int, Path, str, int] + +log = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# FIX-2 (D10 SAT-25-11): probe-derived concurrency constants. +# +# These are NAMED module constants whose VALUES are the probe-derived +# recommendation recorded in the SOURCE-LIMITS.md satellite entry (the +# package-co-located ``SOURCE-LIMITS-satellite.md`` seed until the first live +# probe run overwrites .planning/research/SOURCE-LIMITS.md with the measured +# knee). Each carries a provenance comment citing that entry — mirroring how +# ``NOMADS_CONCURRENCY_CAP`` (_nwp_archive.py:100) is documented as empirically +# derived. Re-measure with: python -m mostlyright.weather.satellite probe +# --------------------------------------------------------------------------- + +#: Provenance source for the two constants below. The shipped seed lives in the +#: package so the provenance lock is testable without touching .planning/; the +#: live probe (D10) writes the measured knee into .planning/research/SOURCE-LIMITS.md. +_SOURCE_LIMITS_PATH: Path = Path(__file__).with_name("SOURCE-LIMITS-satellite.md") + +#: Top-level S3/GCS call-rate cap (Hz). Replaces the 2i fixed +#: ``_GOES_S3_RATE_HZ = 20.0``. Its value is the probe-derived ``derive_rate_cap`` +#: result recorded in the SOURCE-LIMITS.md satellite entry (provenance: +#: SOURCE-LIMITS-satellite.md; conservative-pending the first live probe run). +#: The 25-03 transport's ``_RateLimiter`` consumes this cap. +_GOES_S3_RATE_HZ: float = 20.0 + +#: Default fan-out width for ``bulk_backfill``. Its value is the probe-derived +#: ``derive_max_workers`` result (the knee of the diminishing-returns curve) +#: recorded in the SOURCE-LIMITS.md satellite entry (provenance: +#: SOURCE-LIMITS-satellite.md). Re-measure with +#: ``python -m mostlyright.weather.satellite probe``. Conservative-pending the +#: first live probe run; the provenance-lock test asserts shipped <= recorded. +_DEFAULT_MAX_WORKERS: int = 8 + +# --------------------------------------------------------------------------- +# Resume-layer constants (ported from satellite_backfill_plan_2i.md:40-56). +# --------------------------------------------------------------------------- +_PROGRESS_FILENAME = "satellite_backfill_progress.json" +_PROGRESS_LOCK_FILENAME = "satellite_backfill_progress.lock" +# P1-2: the resume key encodes the FULL slice identity +# ``{satellite}_{product}_{station}_{YYYY}_{MM}``. Product codes use hyphens but +# NO underscores (e.g. ``ABI-L2-ACMC``), and the field separator is ``_``, so +# the components stay unambiguous. station is the 4-char ICAO. The year/month +# tail (``_\d{4}_(0[1-9]|1[0-2])``) is anchored at the END so it cannot be +# confused with the product segment. +_PROGRESS_KEY_RE = re.compile(r"^(goes16|goes19)_[A-Z0-9-]+_[A-Z0-9]{4}_\d{4}_(0[1-9]|1[0-2])$") +_PROGRESS_COMPLETED = "completed" +_PROGRESS_VERSION = 1 + + +class ProgressLockBusy(SatelliteError): + """Another backfill run already holds the single-writer progress lock. + + Raised when the ``O_CREAT|O_EXCL`` lockfile already exists — a second + concurrent run on the same ``out`` directory must not corrupt the shared + checkpoint (D7 single-writer invariant). + """ + + default_error_code = "PROGRESS_LOCK_BUSY" + + +class ProgressCorrupt(SatelliteError): + """The resume progress file (and its ``.bak``) could not be recovered. + + Raised loudly rather than silently skipping work when BOTH the main JSON + and its ``.bak`` are torn/unreadable, or when a schema error (wrong + version, malformed key, non-``completed`` value) is detected. + """ + + default_error_code = "PROGRESS_CORRUPT" + + +# --------------------------------------------------------------------------- +# Result dataclasses (ported VERBATIM in spirit from 2i 1216-1254; the SDK +# variant carries per-(sat,product,year,month) slice granularity + the +# pre-availability skip flag). +# --------------------------------------------------------------------------- +@dataclass(frozen=True) +class ProductBackfillResult: + """Result of backfilling one (satellite, product, station, year, month) slice.""" + + station: str + satellite: str + product: str + year: int + month: int + scans_fetched: int + rows_written: int + duration_s: float + errors: tuple[str, ...] + skipped_pre_availability: bool = False + + +@dataclass(frozen=True) +class BulkBackfillResult: + """Aggregate result across every (satellite, product, station, year, month) slice.""" + + results: tuple[ProductBackfillResult, ...] + total_scans_fetched: int + total_rows_written: int + slices_completed: int + slices_skipped_resume: int + duration_s: float + + +# --------------------------------------------------------------------------- +# Single-slice backfill — direct atomic write (D8), mirror thread-through (D9). +# --------------------------------------------------------------------------- +def backfill_goes_satellite( + *, + station: StationInfo, + satellite: str, + product: str, + year: int, + month: int, + out: Path, + mirror: str = "aws", + max_workers: int = _DEFAULT_MAX_WORKERS, +) -> ProductBackfillResult: + """Backfill ONE ``(satellite, product, station, year, month)`` slice. + + Lists every scan key for the month (per-day, all 24 UTC hours), extracts the + station pixel for each via the 25-03 transport (threading ``mirror`` + through), and writes the deduped rows DIRECTLY to the per-partition cache via + :func:`cache.write_satellite_cache` — NO staging dir, NO upload step (D8). Slices + before the satellite's ``available_since`` clamp are skipped with no I/O. + + ``mirror`` is TRANSPORT ONLY (D9): the cache partition path is identical for + ``"aws"`` and ``"gcp"`` (no mirror segment). + """ + t0 = time.monotonic() + errors: list[str] = [] + + # available_since clamp: skip a whole slice that falls before the + # satellite's first-light date with no I/O (2i 1320 effective_start logic, + # collapsed to the month grain). + available_since = _AVAILABLE_SINCE.get(satellite) + last_day_of_month = _last_day_of_month(year, month) + if available_since is not None and last_day_of_month < available_since: + return ProductBackfillResult( + station=station.icao, + satellite=satellite, + product=product, + year=year, + month=month, + scans_fetched=0, + rows_written=0, + duration_s=time.monotonic() - t0, + errors=(), + skipped_pre_availability=True, + ) + + bucket = _bucket_for(mirror, satellite) + all_hours = list(range(24)) + rows: list[dict[str, Any]] = [] + scan_starts: set[str] = set() + + for day in _days_in_month(year, month): + if available_since is not None and day < available_since: + continue + try: + keys = list_product_keys( + satellite, + product, + day, + all_hours, + mirror=mirror, + ) + except SatelliteError as exc: # GoesS3Error etc. — log + continue + log.warning("list %s/%s/%s failed: %s", satellite, product, day, exc) + errors.append(f"list {day}: {exc}") + continue + for s3_key, size in keys: + try: + recs = extract_pixel( + s3_key, + bucket, + product, + station, + satellite=satellite, + size=size, + ingested_at=None, + mirror=mirror, + ) + except SatelliteError as exc: + log.warning("extract %s failed: %s", s3_key, exc) + errors.append(f"extract {s3_key}: {exc}") + continue + for r in recs: + rows.append(r) + if r.get("scan_start_utc"): + scan_starts.add(str(r["scan_start_utc"])) + + if rows: + # D8: direct per-partition atomic write (no staging dir, no upload + # step). cache dedups + atomic-writes the partition. P2-1: thread ``out`` + # as the cache root so the parquet partition lands UNDER ``--out`` (the + # CLI-advertised output dir) rather than the home/env cache root. + write_satellite_cache(satellite, product, station.icao, year, month, rows, cache_root=out) + + return ProductBackfillResult( + station=station.icao, + satellite=satellite, + product=product, + year=year, + month=month, + scans_fetched=len(scan_starts), + rows_written=len(rows), + duration_s=time.monotonic() - t0, + errors=tuple(errors), + skipped_pre_availability=False, + ) + + +def _bucket_for(mirror: str, satellite: str) -> str: + """Return the transport bucket for ``(mirror, satellite)`` (D9).""" + from mostlyright.weather._fetchers._goes_s3 import _get_buckets + + return _get_buckets(mirror, satellite) + + +# --------------------------------------------------------------------------- +# Bulk orchestrator — slices + resume layer + Thread/Process split (D7). +# --------------------------------------------------------------------------- +def bulk_backfill( + *, + satellites: list[str], + products: list[str], + stations: list[str], + year_start: int, + year_end: int, + out: Path, + resume: bool = True, + progress_path: Path | None = None, + max_workers: int = _DEFAULT_MAX_WORKERS, + executor: str = "thread", + mirror: str = "aws", +) -> BulkBackfillResult: + """Backfill every ``(satellite, product, station, year, month)`` slice. + + Array-job-friendly: each slice is independent, so a later VM fleet can + array-job them. ``executor="thread"`` (default) uses a ``ThreadPoolExecutor`` + (small CONUS files); ``executor="process"`` uses a ``ProcessPoolExecutor`` + for DSRF full-disk decode (D7). ``mirror`` (D9) is threaded into every slice. + + Crash-safe resume (D7): when ``resume=True`` a slice already marked + ``completed`` in the progress file is skipped; the lock is ALWAYS acquired + (even with ``resume=False``) so two runs cannot share the ``out`` directory. + A slice that errors is NOT marked completed (so resume retries it). + """ + t0 = time.monotonic() + out = Path(out) + + # P2-e: validate EVERY partition component at the boundary BEFORE any I/O so + # a malicious --satellites / --products string ("../", "goes16/../..") is + # rejected loudly and NO file is written outside the cache root. The hardened + # cache.satellite_cache_path is the final backstop, but validating here means + # we never even mkdir(out) or enter the executor for a poisoned slice. + _validate_partition_components(satellites, products) + + out.mkdir(parents=True, exist_ok=True) + progress_path = Path(progress_path) if progress_path is not None else out / _PROGRESS_FILENAME + + station_infos = _resolve_station_infos(stations) + slices = _enumerate_slices(satellites, products, station_infos, year_start, year_end) + + results: list[ProductBackfillResult] = [] + slices_skipped_resume = 0 + + # Single-writer lock — ALWAYS acquired (D7), released in finally. + lock_path = out / _PROGRESS_LOCK_FILENAME + _acquire_lock(lock_path) + try: + progress: dict[str, str] = _load_progress(progress_path) if resume else {} + + # Each pending item is a FULLY PICKLABLE tuple carrying everything the + # module-level worker ``_run_slice`` needs — info, sat, product, year, + # month, out, mirror, max_workers. The run-wide params (out/mirror/ + # max_workers) are baked into the item rather than captured by a closure + # so the ``executor="process"`` ProcessPoolExecutor path can pickle the + # payload (P1-1: a nested closure capturing those params is NOT picklable + # and broke every submit before any slice ran). + pending: list[_SliceItem] = [] + for sat, product, info, year, month in slices: + key = _progress_key(sat, product, info.icao, year, month) + if resume and progress.get(key) == _PROGRESS_COMPLETED: + slices_skipped_resume += 1 + continue + pending.append((info, sat, product, year, month, out, mirror, max_workers)) + + pool = _make_executor(executor, max_workers) + with pool: + fut_to_item = {pool.submit(_run_slice, item): item for item in pending} + for fut in as_completed(fut_to_item): + item = fut_to_item[fut] + info, sat, product, year, month = item[0], item[1], item[2], item[3], item[4] + try: + res = fut.result() + except Exception as exc: # a slice that errors is NOT marked + log.exception( + "slice %s/%s/%s %04d-%02d failed", info.icao, sat, product, year, month + ) + res = ProductBackfillResult( + station=info.icao, + satellite=sat, + product=product, + year=year, + month=month, + scans_fetched=0, + rows_written=0, + duration_s=0.0, + errors=(str(exc),), + skipped_pre_availability=False, + ) + results.append(res) + continue + results.append(res) + # Mark completed ONLY when the slice is genuinely TERMINAL + # (P2-2): the (year, month) is FULLY ELAPSED in UTC, OR the slice + # actually persisted rows to the cache. A current/future month + # writes nothing yet (the cache skips the incomplete current + # month; future listings return no rows) and produces no errors, + # so the old `not res.errors` gate wrongly marked it completed and + # a later resume PERMANENTLY skipped it even once data existed. + # + # The `rows_written > 0` clause is GUARDED by "not current month": + # `write_satellite_cache` is a no-op for the current UTC month, so + # a non-zero `rows_written` there reflects scans FETCHED, not + # CACHED — counting it would re-introduce the bug. For any fully + # elapsed month the rows are persisted, so the elapsed check + # already covers genuine writes; the rows clause only ever + # tightens a boundary edge and is never honored for a non-elapsed + # month. + # + # The key encodes the FULL slice identity (sat, product, station, + # year, month) so completing one slice never suppresses a sibling + # (product or station) in the same satellite-month (P1-2). + elapsed = _is_month_fully_elapsed(year, month) + terminal = elapsed or ( + res.rows_written > 0 and not _is_current_utc_month(year, month) + ) + if resume and not res.errors and terminal: + progress[_progress_key(sat, product, info.icao, year, month)] = ( + _PROGRESS_COMPLETED + ) + _save_progress(progress_path, progress) + finally: + _release_lock(lock_path) + + return BulkBackfillResult( + results=tuple(results), + total_scans_fetched=sum(r.scans_fetched for r in results), + total_rows_written=sum(r.rows_written for r in results), + slices_completed=sum(1 for r in results if not r.errors), + slices_skipped_resume=slices_skipped_resume, + duration_s=time.monotonic() - t0, + ) + + +def _run_slice(item: _SliceItem) -> ProductBackfillResult: + """Module-level pool worker — runs ONE slice (P1-1). + + Lives at MODULE scope (not as a nested closure inside ``bulk_backfill``) so + it is picklable by qualified name and can be submitted to a + ``ProcessPoolExecutor``. Every parameter the slice needs travels inside the + fully-picklable ``item`` tuple — ``(station_info, satellite, product, year, + month, out, mirror, max_workers)`` — rather than being captured from an + enclosing function. The 2i nested ``_run`` captured ``out``/``mirror``/ + ``max_workers`` and raised ``PicklingError`` on every ``pool.submit`` under + ``executor="process"``, breaking the documented DSRF process-pool path. + """ + info, sat, product, year, month, out, mirror, max_workers = item + return backfill_goes_satellite( + station=info, + satellite=sat, + product=product, + year=year, + month=month, + out=out, + mirror=mirror, + max_workers=max_workers, + ) + + +def _make_executor(executor: str, max_workers: int) -> Executor: + """Return a Thread or Process pool (D7 — CONUS thread / DSRF process).""" + if executor == "process": + return ProcessPoolExecutor(max_workers=max_workers) + if executor == "thread": + return ThreadPoolExecutor(max_workers=max_workers) + raise ValueError(f"executor must be 'thread' or 'process'; got {executor!r}") + + +def _validate_partition_components(satellites: list[str], products: list[str]) -> None: + """Reject malicious satellite/product strings at the boundary (P2-e). + + Routes the satellite + product enums through the SAME hardened validators + the cache-path builder uses (``_assert_no_path_separator`` + the + ``{goes16, goes19}`` enum + the ``_KNOWN_PRODUCTS`` registry) so a CLI + ``--satellites "goes16/../.."`` / ``--products "ABI-L2-ACMC/../.."`` raises + a loud ``ValueError`` BEFORE any directory is created or any slice runs. + """ + from mostlyright.weather._fetchers._goes_extract import _KNOWN_PRODUCTS + from mostlyright.weather.cache import ( + _SATELLITE_VALID_SATELLITES, + _assert_no_path_separator, + ) + + for sat in satellites: + _assert_no_path_separator(sat, field="satellite") + if sat not in _SATELLITE_VALID_SATELLITES: + raise ValueError( + f"satellite {sat!r} must be one of {sorted(_SATELLITE_VALID_SATELLITES)}" + ) + for product in products: + _assert_no_path_separator(product, field="product") + if product not in _KNOWN_PRODUCTS: + raise ValueError( + f"product {product!r} is not a known registry product " + f"({len(_KNOWN_PRODUCTS)} known)" + ) + + +def _enumerate_slices( + satellites: list[str], + products: list[str], + station_infos: list[StationInfo], + year_start: int, + year_end: int, +) -> list[tuple[str, str, StationInfo, int, int]]: + """Enumerate per-(satellite, product, station, year, month) slices.""" + out: list[tuple[str, str, StationInfo, int, int]] = [] + for sat in satellites: + for product in products: + for info in station_infos: + for year in range(year_start, year_end + 1): + for month in range(1, 13): + out.append((sat, product, info, year, month)) + return out + + +# --------------------------------------------------------------------------- +# Resume layer — durable progress (fsync) + .bak fallback + validation. +# --------------------------------------------------------------------------- +def _progress_key(satellite: str, product: str, station: str, year: int, month: int) -> str: + """Return the resume key encoding the FULL slice identity (P1-2). + + A slice is ``(satellite, product, station, year, month)``; the key MUST + carry all five so completing one slice never suppresses a sibling differing + only in ``product`` or ``station``. The 2i key dropped product AND station + (``"{satellite}_{year}_{month}"``), causing silent data loss on resume. + """ + return f"{satellite}_{product}_{station}_{year:04d}_{month:02d}" + + +def _validate_progress(progress: dict[str, Any]) -> dict[str, str]: + """Validate keys/values; raise :class:`ProgressCorrupt` on any schema error. + + Hand-edited / partial-seeded files cannot silently suppress work: a key not + matching the full-identity schema + ``^(goes16|goes19)_[A-Z0-9-]+_[A-Z0-9]{4}_\\d{4}_(0[1-9]|1[0-2])$`` (P1-2: + satellite_product_station_YYYY_MM) or any value other than ``"completed"`` + raises immediately (no ``.bak`` fallback for schema errors). + """ + if not isinstance(progress, dict): + raise ProgressCorrupt("progress payload is not a JSON object") + version = progress.get("__version__", _PROGRESS_VERSION) + if version != _PROGRESS_VERSION: + raise ProgressCorrupt(f"progress version {version!r} != {_PROGRESS_VERSION}") + out: dict[str, str] = {} + for key, value in progress.items(): + if key == "__version__": + continue + if not isinstance(key, str) or not _PROGRESS_KEY_RE.match(key): + raise ProgressCorrupt(f"invalid progress key {key!r}") + if value != _PROGRESS_COMPLETED: + raise ProgressCorrupt(f"invalid progress value {value!r} for key {key!r}") + out[key] = value + return out + + +def _load_progress(progress_path: Path) -> dict[str, str]: + """Load + validate the progress map; torn main -> ``.bak``; both-torn raises. + + A *schema* error (wrong version, malformed key/value) ALWAYS raises — no + backup fallback — so a tampered file cannot suppress work. A *torn* (JSON + decode) main file falls back to ``.bak``; if BOTH are torn, raises loudly. + """ + bak_path = progress_path.with_suffix(progress_path.suffix + ".bak") + if not progress_path.exists(): + return {} + try: + raw = json.loads(progress_path.read_text()) + except json.JSONDecodeError: + # Torn main -> try .bak. + if bak_path.exists(): + try: + raw = json.loads(bak_path.read_text()) + except json.JSONDecodeError as exc: + raise ProgressCorrupt( + f"both {progress_path.name} and its .bak are torn JSON" + ) from exc + else: + raise ProgressCorrupt(f"{progress_path.name} is torn JSON and no .bak exists") from None + return _validate_progress(raw) + + +def _save_progress(progress_path: Path, progress: dict[str, str]) -> None: + """Durably persist the progress map (fsync + .bak + os.sync barrier). + + Order (2i hardened design): ``os.sync()`` barrier BEFORE the mark so parquet + page-cache writes land before the marker references them; snapshot the + current main file to ``.bak``; write ``*.json.tmp`` -> ``fsync`` tmp -> + ``os.replace`` -> ``fsync`` parent dir. + """ + bak_path = progress_path.with_suffix(progress_path.suffix + ".bak") + tmp_path = progress_path.with_suffix(progress_path.suffix + ".tmp") + + # Barrier: ensure the data the marker is about to reference is on disk. + os.sync() + + # Snapshot the previous revision to .bak (best-effort — absent on first save). + if progress_path.exists(): + bak_path.write_bytes(progress_path.read_bytes()) + + payload = {"__version__": _PROGRESS_VERSION, **progress} + data = json.dumps(payload, indent=2, sort_keys=True).encode() + + fd = os.open(str(tmp_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644) + try: + os.write(fd, data) + os.fsync(fd) + finally: + os.close(fd) + os.replace(tmp_path, progress_path) + + # fsync the parent directory so the rename is durable. + dir_fd = os.open(str(progress_path.parent), os.O_RDONLY) + try: + os.fsync(dir_fd) + finally: + os.close(dir_fd) + + +# --------------------------------------------------------------------------- +# Single-writer lock — O_CREAT|O_EXCL (PID+hostname), released in finally. +# --------------------------------------------------------------------------- +def _acquire_lock(lock_path: Path) -> None: + """Create the lockfile O_CREAT|O_EXCL; raise :class:`ProgressLockBusy` if held.""" + try: + fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) + except FileExistsError as exc: + raise ProgressLockBusy( + f"another backfill run holds {lock_path.name} (single-writer lock)" + ) from exc + try: + os.write(fd, f"pid={os.getpid()} host={socket.gethostname()}\n".encode()) + finally: + os.close(fd) + + +def _release_lock(lock_path: Path) -> None: + """Remove the lockfile (in ``finally``).""" + with contextlib.suppress(FileNotFoundError): + lock_path.unlink() + + +# --------------------------------------------------------------------------- +# Date helpers (stdlib only). +# --------------------------------------------------------------------------- +def _is_current_utc_month(year: int, month: int) -> bool: + """True if ``(year, month)`` is the current UTC month. + + Mirrors the cache tier's current-UTC-month skip (``read/write_satellite_cache`` + no-op the current month because it is still receiving scans). GOES scans are + keyed in UTC, so the satellite backfill uses UTC — not station LST. + """ + from datetime import UTC, datetime + + now = datetime.now(UTC) + return now.year == year and now.month == month + + +def _is_month_fully_elapsed(year: int, month: int) -> bool: + """True if every instant of ``(year, month)`` is strictly in the UTC past. + + A month is fully elapsed once the first instant of the FOLLOWING month + (``YYYY-MM+1-01 00:00:00 UTC``) is ``<=`` now-UTC — i.e. the whole month has + rolled over. Such a slice is genuinely TERMINAL: its data is stable and any + rows it has are already persisted, so it is safe to mark ``completed`` (P2-2). + A current or future month returns ``False`` and is therefore re-attempted on + a later resume once data exists. + """ + from datetime import UTC, datetime, timedelta + + now = datetime.now(UTC) + last_day = _last_day_of_month(year, month) + # First instant after the month ends (start of the next day after the last). + month_end_exclusive = datetime( + last_day.year, last_day.month, last_day.day, tzinfo=UTC + ) + timedelta(days=1) + return month_end_exclusive <= now + + +def _last_day_of_month(year: int, month: int) -> date: + if month == 12: + return date(year, 12, 31) + from datetime import timedelta + + return date(year, month + 1, 1) - timedelta(days=1) + + +def _days_in_month(year: int, month: int) -> list[date]: + from datetime import timedelta + + cur = date(year, month, 1) + last = _last_day_of_month(year, month) + out: list[date] = [] + while cur <= last: + out.append(cur) + cur = cur + timedelta(days=1) + return out + + +__all__ = [ + "BulkBackfillResult", + "ProductBackfillResult", + "ProgressCorrupt", + "ProgressLockBusy", + "backfill_goes_satellite", + "bulk_backfill", +] diff --git a/packages/weather/src/mostlyright/weather/satellite/_probe.py b/packages/weather/src/mostlyright/weather/satellite/_probe.py new file mode 100644 index 0000000..e7ff114 --- /dev/null +++ b/packages/weather/src/mostlyright/weather/satellite/_probe.py @@ -0,0 +1,443 @@ +"""Empirical rate-limit / throughput probe for GOES ABI L2 NODD (D10 SAT-25-11). + +Converts the ported fixed ~20 Hz limiter + the "UNTUNED max_workers / never- +measured saturation curve" open item into an empirically-grounded (or grounded- +on-demand) default that the shipped concurrency constants ACTUALLY consume — +exactly mirroring how ``NOMADS_CONCURRENCY_CAP`` + ``SOURCE-LIMITS.md`` were +derived for NWP. + +``run_probe`` measures, against the LIVE NOAA buckets: + + 1. ListObjectsV2 latency (a few repeats), + 2. single-file CONUS-ACMC fetch throughput (bytes/s), + 3. a concurrency sweep (1/4/8/16/32 workers) recording per-N p50/p95/p99 + latency, aggregate throughput, and error/throttle counts — to find the + anonymous-throttle / diminishing-returns KNEE. + +``derive_rate_cap`` / ``derive_max_workers`` turn the sweep into the empirical +rate-limiter cap (Hz) + default ``max_workers`` (the knee). These ARE the values +``_backfill._GOES_S3_RATE_HZ`` / ``_backfill._DEFAULT_MAX_WORKERS`` are set to; +the Task-1 provenance-lock test asserts the shipped constants are floored at / +match the values recorded in the SOURCE-LIMITS.md satellite entry. + +**FIX-2 — BOTH artifacts.** ``run_probe`` writes (1) a findings artifact under +``out_dir`` MIRRORING the SOURCE-LIMITS.md format (a Summary, the per-N sweep +table, a Scope caveat, the derived rate-cap + max_workers recommendation, and a +re-run-command pointer) AND (2) a satellite section appended/updated in +``/SOURCE-LIMITS.md`` recording the derived values in a machine-readable +form that :func:`read_source_limits_satellite` parses back (the provenance round +trip the shipped constants depend on). + +**Network isolation.** The measurement loop is structured so the three live +measurement functions are INJECTABLE (``_measure_fn`` / ``_list_latency_fn`` / +``_single_file_throughput_fn``). CI tests inject synthetic inputs and never hit +the network; the real run (no injection) is exercised only by the +``@pytest.mark.live`` test / the on-demand CLI. +""" + +from __future__ import annotations + +import re +from collections.abc import Callable +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path + +# The conservative-pending floors the probe never goes BELOW when deriving a cap +# from a degenerate measurement (so a flaky live run can only RAISE, never drop +# the cap to something unusable). They equal the package seed values. +_FLOOR_RATE_HZ = 20.0 +_FLOOR_MAX_WORKERS = 1 + +#: Default findings-artifact filename written under ``out_dir``. +_FINDINGS_FILENAME = "satellite-throughput-probe.md" + +#: The probe re-run command documented in every artifact. +_RERUN_CMD = "python -m mostlyright.weather.satellite probe --mirror aws --out .planning/research" + + +# --------------------------------------------------------------------------- +# Result types +# --------------------------------------------------------------------------- +@dataclass(frozen=True) +class SweepSample: + """Aggregates for one concurrency level N of the sweep.""" + + n_workers: int + reqs: int + p50_s: float + p95_s: float + p99_s: float + throughput_Bps: float + errors: int + status_dist: dict[int, int] = field(default_factory=dict) + + +@dataclass(frozen=True) +class ProbeResult: + """Structured result of one ``run_probe`` invocation.""" + + mirror: str + sweep: tuple[SweepSample, ...] + list_latency_s: float + single_file_throughput_Bps: float + rate_cap_hz: float + max_workers: int + findings_filename: str = _FINDINGS_FILENAME + + +# --------------------------------------------------------------------------- +# Derivation — turn the sweep into the empirical defaults (the KNEE). +# --------------------------------------------------------------------------- +def derive_max_workers(result: ProbeResult) -> int: + """Return the knee N — the largest worker count before throughput flattens + or errors/throttles appear. + + Walk the sweep in ascending N. Stop at the first N that either (a) shows any + errors (anonymous throttle kicked in) or (b) fails to improve aggregate + throughput by more than a 5% margin over the previous N (diminishing + returns). The knee is the last N BEFORE that point. Deterministic for a + fixed sweep. Floored at ``_FLOOR_MAX_WORKERS``. + """ + samples = sorted(result.sweep, key=lambda s: s.n_workers) + if not samples: + return _FLOOR_MAX_WORKERS + knee = samples[0].n_workers + prev_tp = samples[0].throughput_Bps + for s in samples[1:]: + if s.errors > 0: + break # throttle observed — do not go this high + if s.throughput_Bps <= prev_tp * 1.05: + break # diminishing returns — the previous N is the knee + knee = s.n_workers + prev_tp = s.throughput_Bps + return max(knee, _FLOOR_MAX_WORKERS) + + +def derive_rate_cap(result: ProbeResult) -> float: + """Return a finite Hz rate-limiter cap derived from the sweep. + + The cap is the per-worker request rate at the knee: at the knee N the system + is saturated without throttling, so ``knee_N / p50_latency`` is the safe + aggregate call rate. Floored at ``_FLOOR_RATE_HZ`` so a degenerate / flaky + measurement can only RAISE (never drop) the conservative-pending cap. + Deterministic for a fixed sweep. + """ + knee = derive_max_workers(result) + samples = {s.n_workers: s for s in result.sweep} + knee_sample = samples.get(knee) + if knee_sample is None or knee_sample.p50_s <= 0: + return _FLOOR_RATE_HZ + observed = knee / knee_sample.p50_s + return max(round(observed, 1), _FLOOR_RATE_HZ) + + +# --------------------------------------------------------------------------- +# Probe driver +# --------------------------------------------------------------------------- +def run_probe( + *, + mirror: str = "aws", + out_dir: Path = Path(".planning/research"), + sweep: tuple[int, ...] = (1, 4, 8, 16, 32), + _measure_fn: Callable[[int], SweepSample] | None = None, + _list_latency_fn: Callable[[], float] | None = None, + _single_file_throughput_fn: Callable[[], float] | None = None, +) -> ProbeResult: + """Run the probe and write BOTH artifacts under ``out_dir``. + + The three live-measurement functions are injectable so CI tests run the + aggregation + artifact-write logic with synthetic inputs (network-free); the + real run (no injection) measures against the LIVE NOAA buckets. + """ + if mirror not in {"aws", "gcp"}: + raise ValueError(f"mirror must be one of ['aws', 'gcp']; got {mirror!r}") + + measure = _measure_fn or _live_measure_for(mirror) + list_latency_fn = _list_latency_fn or (lambda: _live_list_latency(mirror)) + single_tp_fn = _single_file_throughput_fn or (lambda: _live_single_file_throughput(mirror)) + + list_latency_s = list_latency_fn() + single_file_throughput_Bps = single_tp_fn() + samples = tuple(measure(n) for n in sorted(set(sweep))) + + # Build a partial result so derive_* can run on the sweep. + partial = ProbeResult( + mirror=mirror, + sweep=samples, + list_latency_s=list_latency_s, + single_file_throughput_Bps=single_file_throughput_Bps, + rate_cap_hz=_FLOOR_RATE_HZ, + max_workers=_FLOOR_MAX_WORKERS, + ) + max_workers = derive_max_workers(partial) + rate_cap_hz = derive_rate_cap(partial) + result = ProbeResult( + mirror=mirror, + sweep=samples, + list_latency_s=list_latency_s, + single_file_throughput_Bps=single_file_throughput_Bps, + rate_cap_hz=rate_cap_hz, + max_workers=max_workers, + ) + + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + _write_findings(out_dir / _FINDINGS_FILENAME, result) + _write_source_limits_satellite(out_dir / "SOURCE-LIMITS.md", result) + return result + + +# --------------------------------------------------------------------------- +# Live measurement (only reached when no injection — i.e. the real / live run). +# --------------------------------------------------------------------------- +def _live_list_latency(mirror: str) -> float: # pragma: no cover — live-only + """Measure ListObjectsV2 / gcsfs-ls latency over a few repeats (live).""" + import statistics + import time + from datetime import date + + from mostlyright.weather._fetchers import _goes_s3 + + samples: list[float] = [] + for _ in range(3): + t0 = time.monotonic() + _goes_s3.list_product_keys("goes16", "ABI-L2-ACMC", date(2024, 6, 15), [18], mirror=mirror) + samples.append(time.monotonic() - t0) + return statistics.median(samples) + + +def _live_single_file_throughput(mirror: str) -> float: # pragma: no cover — live-only + """Measure single-file CONUS-ACMC fetch throughput in bytes/s (live).""" + import time + from datetime import date + + from mostlyright.weather._fetchers import _goes_s3 + + keys = _goes_s3.list_product_keys( + "goes16", "ABI-L2-ACMC", date(2024, 6, 15), [18], mirror=mirror + ) + if not keys: + return 0.0 + s3_key, _size = keys[0] + fs = _goes_s3._get_fs(mirror) + bucket = _goes_s3._get_buckets(mirror, "goes16") + scheme = "s3" if mirror == "aws" else "gs" + uri = f"{scheme}://{bucket}/{s3_key}" + t0 = time.monotonic() + data = _goes_s3._read_full_object(fs, uri) + elapsed = time.monotonic() - t0 + return (len(data) / elapsed) if elapsed > 0 else 0.0 + + +def _live_measure_for(mirror: str) -> Callable[[int], SweepSample]: # pragma: no cover — live-only + """Return a per-N live concurrency-sweep measurement closure.""" + import statistics + import time + from concurrent.futures import ThreadPoolExecutor, as_completed + from datetime import date + + from mostlyright.weather._fetchers import _goes_s3 + + def _measure(n_workers: int) -> SweepSample: + keys = _goes_s3.list_product_keys( + "goes16", "ABI-L2-ACMC", date(2024, 6, 15), list(range(24)), mirror=mirror + )[: max(n_workers * 2, n_workers)] + if not keys: + return SweepSample(n_workers, 0, 0.0, 0.0, 0.0, 0.0, 0, {}) + fs = _goes_s3._get_fs(mirror) + bucket = _goes_s3._get_buckets(mirror, "goes16") + scheme = "s3" if mirror == "aws" else "gs" + + latencies: list[float] = [] + total_bytes = 0 + errors = 0 + status: dict[int, int] = {} + + def _one(item: tuple[str, int]) -> tuple[float, int]: + s3_key, _size = item + uri = f"{scheme}://{bucket}/{s3_key}" + t0 = time.monotonic() + data = _goes_s3._read_full_object(fs, uri) + return time.monotonic() - t0, len(data) + + wall0 = time.monotonic() + with ThreadPoolExecutor(max_workers=n_workers) as ex: + futs = {ex.submit(_one, k): k for k in keys} + for fut in as_completed(futs): + try: + lat, nbytes = fut.result() + latencies.append(lat) + total_bytes += nbytes + status[200] = status.get(200, 0) + 1 + except Exception: + errors += 1 + status[503] = status.get(503, 0) + 1 + wall = time.monotonic() - wall0 + if not latencies: + return SweepSample(n_workers, len(keys), 0.0, 0.0, 0.0, 0.0, errors, status) + ordered = sorted(latencies) + return SweepSample( + n_workers=n_workers, + reqs=len(keys), + p50_s=statistics.median(ordered), + p95_s=ordered[min(len(ordered) - 1, int(len(ordered) * 0.95))], + p99_s=ordered[min(len(ordered) - 1, int(len(ordered) * 0.99))], + throughput_Bps=(total_bytes / wall) if wall > 0 else 0.0, + errors=errors, + status_dist=status, + ) + + return _measure + + +# --------------------------------------------------------------------------- +# Artifact writers (mirror the SOURCE-LIMITS.md format). +# --------------------------------------------------------------------------- +def _write_findings(path: Path, result: ProbeResult) -> None: + rows = "\n".join( + f"| N={s.n_workers} | {s.reqs} | {s.p50_s:.3f} | {s.p95_s:.3f} | " + f"{s.p99_s:.3f} | {s.status_dist} | {s.throughput_Bps:.0f} | {s.errors} |" + for s in sorted(result.sweep, key=lambda s: s.n_workers) + ) + text = f"""# GOES ABI L2 NODD — throughput / rate-limit probe findings + +**Probe date:** {datetime.now(UTC).strftime("%Y-%m-%d")} +**Mirror:** {result.mirror} +**Re-run command:** `{_RERUN_CMD}` + +## Summary + +- ListObjectsV2 latency (median): {result.list_latency_s:.3f} s +- Single-file CONUS-ACMC throughput: {result.single_file_throughput_Bps:.0f} B/s +- **derive_rate_cap: {result.rate_cap_hz:.1f} Hz** +- **derive_max_workers: {result.max_workers}** (the diminishing-returns / throttle knee) + +These derived values govern the shipped `_GOES_S3_RATE_HZ` / `_DEFAULT_MAX_WORKERS` +constants in `satellite/_backfill.py` (provenance lock: the shipped constants are +floored at / match the SOURCE-LIMITS.md satellite-entry values). + +## Concurrency sweep + +| N (concurrent) | reqs | p50_s | p95_s | p99_s | status_dist | throughput_Bps | err | +|---|---|---|---|---|---|---|---| +{rows} + +## Scope caveat + +These are the values measured by the most recent probe run against the anonymous +NOAA NODD buckets — NOT a guaranteed published policy. The buckets have no +published per-IP throttle; the knee is the empirical diminishing-returns / +first-error point of THIS run. Re-run `{_RERUN_CMD}` to re-measure (e.g. from a +different region or after an upstream policy change). Lowering the recorded cap +(more conservative) is always safe; raising it above the measured knee is caught +by the provenance-lock test. + +## Inputs to other plans + +- **25-05 backfill constants:** `_GOES_S3_RATE_HZ` and `_DEFAULT_MAX_WORKERS` are + floored at the derived values above. The provenance-lock test asserts + `shipped <= recorded`. +""" + path.write_text(text) + + +_SAT_SECTION_HEADER = "## GOES ABI L2 NODD satellite (D10 SAT-25-11)" +# Machine-readable provenance markers parsed back by read_source_limits_satellite. +_RATE_MARKER = "derive_rate_cap" +_WORKERS_MARKER = "derive_max_workers" + + +def _write_source_limits_satellite(path: Path, result: ProbeResult) -> None: + """Append/update the satellite section in ``/SOURCE-LIMITS.md``. + + Idempotent: an existing satellite section is replaced; the rest of the file + is preserved (so a real run against the project SOURCE-LIMITS.md does not + clobber the AWC/IEM/GHCNh sections). + """ + section = f"""{_SAT_SECTION_HEADER} + +**Probe date:** {datetime.now(UTC).strftime("%Y-%m-%d")} · **Mirror:** {result.mirror} +**Re-run command:** `{_RERUN_CMD}` + +The empirical rate-limit / throughput probe (D10 SAT-25-11) measured the +anonymous-throttle / diminishing-returns knee against the NOAA NODD buckets. The +shipped `_GOES_S3_RATE_HZ` / `_DEFAULT_MAX_WORKERS` constants in +`satellite/_backfill.py` are floored at / match these recorded values +(provenance lock). + +| metric | value | +|---|---| +| {_RATE_MARKER} | {result.rate_cap_hz:.1f} Hz | +| {_WORKERS_MARKER} | {result.max_workers} | +| ListObjectsV2 latency (median) | {result.list_latency_s:.3f} s | +| single-file throughput | {result.single_file_throughput_Bps:.0f} B/s | +""" + if path.exists(): + existing = path.read_text() + existing = _strip_satellite_section(existing) + new = existing.rstrip() + "\n\n" + section + else: + new = "# SOURCE-LIMITS.md\n\n" + section + path.write_text(new) + + +def _strip_satellite_section(text: str) -> str: + """Remove an existing satellite section (idempotent re-write).""" + lines = text.splitlines(keepends=True) + out: list[str] = [] + skipping = False + for line in lines: + if line.startswith(_SAT_SECTION_HEADER): + skipping = True + continue + if skipping and line.startswith("## ") and not line.startswith(_SAT_SECTION_HEADER): + skipping = False + if not skipping: + out.append(line) + return "".join(out) + + +# --------------------------------------------------------------------------- +# Provenance reader (the round trip the Task-1 provenance lock depends on). +# --------------------------------------------------------------------------- +_RATE_RE = re.compile(r"derive_rate_cap\s*\|\s*([0-9]+(?:\.[0-9]+)?)\s*Hz", re.IGNORECASE) +_WORKERS_RE = re.compile(r"derive_max_workers\s*\|\s*([0-9]+)", re.IGNORECASE) +# Also accept the package-seed bullet form ("derive_rate_cap: **20.0** Hz"). +_RATE_BULLET_RE = re.compile( + r"derive_rate_cap[:\s]*\**\s*([0-9]+(?:\.[0-9]+)?)\s*\**\s*Hz", re.IGNORECASE +) +_WORKERS_BULLET_RE = re.compile(r"derive_max_workers[:\s]*\**\s*([0-9]+)", re.IGNORECASE) + + +def read_source_limits_satellite(path: Path) -> dict[str, float] | None: + """Read back the recorded satellite ``{rate_hz, max_workers}`` or ``None``. + + Returns ``None`` when the file has no satellite section (so a caller can + fall back to a conservative default). Parses BOTH the probe's table form and + the package seed's bullet form so the same helper reads either artifact. + """ + path = Path(path) + if not path.exists(): + return None + text = path.read_text() + if "satellite" not in text.lower() or "derive_rate_cap" not in text: + return None + rate_match = _RATE_RE.search(text) or _RATE_BULLET_RE.search(text) + workers_match = _WORKERS_RE.search(text) or _WORKERS_BULLET_RE.search(text) + if not rate_match or not workers_match: + return None + return { + "rate_hz": float(rate_match.group(1)), + "max_workers": int(workers_match.group(1)), + } + + +__all__ = [ + "ProbeResult", + "SweepSample", + "derive_max_workers", + "derive_rate_cap", + "read_source_limits_satellite", + "run_probe", +] diff --git a/packages/weather/src/mostlyright/weather/satellite/_resolve.py b/packages/weather/src/mostlyright/weather/satellite/_resolve.py new file mode 100644 index 0000000..ea12493 --- /dev/null +++ b/packages/weather/src/mostlyright/weather/satellite/_resolve.py @@ -0,0 +1,69 @@ +"""Station ICAO resolve for the satellite fetcher (Phase 25 Wave 3 / 25-04). + +Parallels :func:`mostlyright.weather.forecast_nwp._resolve_stations` (the +alias-dedup-on-``.icao`` path) but returns the resolved +:class:`~mostlyright._internal._stations.StationInfo` OBJECTS rather than just +the input strings. The backfill loop in 25-05 needs ``.icao``/``.name`` for +logging + identity, and the extractor's ``_build_record`` consumes +``.latitude``/``.longitude``/``.icao`` directly — so passing the full +``StationInfo`` through avoids a second lookup at the call site. + +D2 (USER-LOCKED): station identity for satellite rows is the 4-letter ICAO. +This resolver dedups aliases on ``info.icao`` (so ``["NYC", "KNYC"]`` collapse +to one) and skips unknown codes with a logged warning rather than raising — a +quant fetching ``["KNYC", "ZZZZ"]`` keeps KNYC and loses only the unknown. +""" + +from __future__ import annotations + +import logging + +from mostlyright._internal._stations import STATIONS, StationInfo + +log = logging.getLogger(__name__) + +__all__ = ["_resolve_station_infos"] + + +def _resolve_station_infos(stations: list[str]) -> list[StationInfo]: + """Resolve ICAO / NWS codes to :class:`StationInfo` objects (alias-dedup). + + Mirrors ``forecast_nwp._resolve_stations``' resolution + alias-dedup + discipline but returns the canonical ``StationInfo`` objects: + + - Each input is looked up first by registry key (NWS 3-letter), then by + ICAO (4-letter). The first occurrence of each canonical ICAO wins; + later aliases are dropped with a logged warning (no silent + double-count when a downstream user unions runs). + - An unknown station is skipped with a logged warning and never raises — + matching ``_resolve_stations``' partial-list contract. + + Args: + stations: ICAO / NWS codes (e.g. ``["KNYC", "NYC", "EGLL"]``). + + Returns: + Resolved ``StationInfo`` objects in input order, deduped on ``.icao``. + """ + out: list[StationInfo] = [] + seen_canonical: set[str] = set() + for s in stations: + info = STATIONS.get(s) + if info is None: + for v in STATIONS.values(): + if v.icao == s: + info = v + break + if info is None: + log.warning("satellite: skipping unknown station %r", s) + continue + canonical = info.icao + if canonical in seen_canonical: + log.warning( + "satellite: dropping alias %r — already resolved as %r", + s, + canonical, + ) + continue + seen_canonical.add(canonical) + out.append(info) + return out diff --git a/packages/weather/tests/conftest.py b/packages/weather/tests/conftest.py new file mode 100644 index 0000000..0e7f912 --- /dev/null +++ b/packages/weather/tests/conftest.py @@ -0,0 +1,438 @@ +"""Shared pytest fixtures for the weather package test suite. + +Phase 25 adds synthetic in-memory ``xr.Dataset`` fixtures — one per GOES ABI +L2 registry quirk — so ``_goes_extract._extract_from_dataset`` can be tested +with ZERO network and ZERO checked-in binary NetCDF. Mirrors how +``test_forecast_nwp.py`` stubs datasets (in-memory, no GRIB on disk). + +Each fixture builds a tiny grid (3x3 / 5x5) — the ABI scan-angle projection +inversion is exact and does not need full-disk size. The fixtures inject a +``goes_imager_projection`` variable (ABI fixed grid) or a +``goes_lat_lon_projection`` variable (regular lat/lon grid, goes16 DSRF) so +both projection branches are exercisable, and they wire the x/y (or lat/lon) +coord scale/offset so a known station lat/lon lands on a center pixel. + +xarray / numpy are provided by the ``[satellite]`` optional extra; if it is +absent these fixtures (and the tests that request them) are skipped. +""" + +from __future__ import annotations + +import math +from typing import Any + +import pytest + +# NB: use a plain try/except (not ``pytest.importorskip`` at module top level). +# Under coverage's import-tracing on Python 3.14, a top-level +# ``pytest.importorskip`` re-imports the module and trips a spurious +# "cannot load module more than once per process" skip that aborts collection. +# When the [satellite] extra is absent these fixtures are simply not requested +# (the satellite test module guards itself), so a plain optional import is +# sufficient here. +try: + import numpy as np + import xarray as xr +except ImportError: # pragma: no cover + np = None # type: ignore[assignment] + xr = None # type: ignore[assignment] + + +# Reference station used across the projection fixtures: KNYC (Central Park). +STATION_LAT = 40.7789 +STATION_LON = -73.9692 + +# GOES-East nadir longitude (the projection origin written into the fixture). +NADIR_LON = -75.0 + +# ABI fixed-grid ellipsoid constants (GRS80, as published in real GOES files). +_R_EQ = 6378137.0 +_R_POL = 6356752.31414 +_PPH = 35786023.0 # perspective_point_height + + +def _abi_scan_angles(lat_deg: float, lon_deg: float, nadir_lon: float) -> tuple[float, float]: + """Forward ABI scan-angle projection (PUG Vol5 App A) — fixture helper. + + Computed independently of the module under test so the fixtures place the + station at a known scan angle without importing the port (keeps RED honest + when the port does not yet exist). + """ + h = _PPH + _R_EQ + e = math.sqrt(1.0 - (_R_POL / _R_EQ) ** 2) + lam0 = math.radians(nadir_lon) + phi = math.radians(lat_deg) + lam = math.radians(lon_deg) + phi_c = math.atan((_R_POL / _R_EQ) ** 2 * math.tan(phi)) + r_c = _R_POL / math.sqrt(1.0 - (e * math.cos(phi_c)) ** 2) + sx = h - r_c * math.cos(phi_c) * math.cos(lam - lam0) + sy = -r_c * math.cos(phi_c) * math.sin(lam - lam0) + sz = r_c * math.sin(phi_c) + y_scan = math.atan(sz / sx) + x_scan = math.asin(-sy / math.sqrt(sx**2 + sy**2 + sz**2)) + return x_scan, y_scan + + +def _make_imager_projection_var() -> xr.DataArray: + """A ``goes_imager_projection`` scalar var carrying the 4 required ABI attrs.""" + return xr.DataArray( + np.int8(-127), + attrs={ + "grid_mapping_name": "geostationary", + "perspective_point_height": _PPH, + "semi_major_axis": _R_EQ, + "semi_minor_axis": _R_POL, + "longitude_of_projection_origin": NADIR_LON, + "sweep_angle_axis": "x", + }, + ) + + +def _abi_grid_coords( + n: int = 5, +) -> tuple[dict[str, xr.DataArray], int, int]: + """Build x/y coords (with scale_factor/add_offset) centering the station. + + Returns (coords, center_row, center_col). The station's scan angle becomes + the add_offset of the *center* index, and the scale_factor is a small + radian step, so ``compute_pixel_indices`` rounds the station to the grid + center. + """ + x_scan, y_scan = _abi_scan_angles(STATION_LAT, STATION_LON, NADIR_LON) + center = n // 2 + x_scale = 5.6e-5 # ~2 km nadir step, the real ABI CONUS-ish value + y_scale = -5.6e-5 # y decreases downward in ABI fixed grid + # offset such that raw index `center` maps to the station scan angle: + # value(idx) = idx * scale + offset => offset = scan - center*scale + x_offset = x_scan - center * x_scale + y_offset = y_scan - center * y_scale + x_vals = (np.arange(n) * x_scale + x_offset).astype(np.float64) + y_vals = (np.arange(n) * y_scale + y_offset).astype(np.float64) + x = xr.DataArray( + x_vals, + dims=("x",), + attrs={"scale_factor": x_scale, "add_offset": x_offset, "units": "rad"}, + ) + y = xr.DataArray( + y_vals, + dims=("y",), + attrs={"scale_factor": y_scale, "add_offset": y_offset, "units": "rad"}, + ) + return {"x": x, "y": y}, center, center + + +def _base_abi_dataset(n: int = 5) -> tuple[xr.Dataset, int, int]: + """An ABI fixed-grid dataset shell (projection var + x/y coords), no data var.""" + coords, row, col = _abi_grid_coords(n) + ds = xr.Dataset( + data_vars={"goes_imager_projection": _make_imager_projection_var()}, + coords=coords, + ) + return ds, row, col + + +def _dqf_var(n: int, row: int, col: int, good: int = 0) -> xr.DataArray: + """A DQF integer var that is ``good`` at the station cell, ``-1`` elsewhere. + + -1 is the int8 "bad/fill" sentinel here (255 does not fit signed int8). + """ + arr = np.full((n, n), -1, dtype=np.int8) + arr[row, col] = good + return xr.DataArray(arr, dims=("y", "x")) + + +# --------------------------------------------------------------------------- +# Fixture 1 — LST at the [100, 350] K floor (nighttime cold scene) +# --------------------------------------------------------------------------- +@pytest.fixture +def ds_lst_cold() -> dict[str, Any]: + """LST var with a raw value decoding to ~170 K (below the old 180 K floor). + + NOAA LST is int16 packed: physical = raw*scale + offset. We pick + scale/offset so the station cell decodes to ~170 K and set a wide + ``valid_range`` so the value is INSIDE it (the LST quirk is that the + registry floor is [100,350], not that valid_range rejects it). + """ + n = 5 + ds, row, col = _base_abi_dataset(n) + scale = 0.01 + offset = 100.0 + # target ~170 K => raw = (170 - 100)/0.01 = 7000 + raw = np.zeros((n, n), dtype=np.int16) + raw[row, col] = 7000 + lst = xr.DataArray( + raw, + dims=("y", "x"), + attrs={ + "scale_factor": scale, + "add_offset": offset, + "units": "K", + "_FillValue": np.int16(-1), + "valid_range": np.array([0, 30000], dtype=np.int16), + }, + ) + ds["LST"] = lst + ds["DQF"] = _dqf_var(n, row, col, good=0) + return {"ds": ds, "row": row, "col": col, "expected_k": 170.0} + + +# --------------------------------------------------------------------------- +# Fixture 2 — Cloud_Probabilities with valid_range declared in PHYSICAL units +# --------------------------------------------------------------------------- +@pytest.fixture +def ds_cloud_prob_physical_range() -> dict[str, Any]: + """Cloud_Probabilities with ``valid_range=[0,1]`` in physical (not raw) units. + + Applying raw*scale+offset to that range collapses it to [0, 1.5e-5] and + would reject every real point — the registry sets valid_range_filter=False. + The station cell decodes to ~0.8 (a valid probability) which is OUTSIDE + the wrongly-scaled range, so the test proves the filter is bypassed. + """ + n = 5 + ds, row, col = _base_abi_dataset(n) + scale = 3.05e-5 # small [0,1]-style scale (fits 0.8 within int16 range) + offset = 0.0 + raw = np.zeros((n, n), dtype=np.int16) + raw[row, col] = round(0.8 / scale) # decodes to ~0.8 (~26230, < 32767) + cp = xr.DataArray( + raw, + dims=("y", "x"), + attrs={ + "scale_factor": scale, + "add_offset": offset, + # NO units attr on the real file (same as ACM) — empty string. + "_FillValue": np.int16(-1), + "valid_range": np.array([0, 1], dtype=np.int16), # PHYSICAL units + }, + ) + ds["Cloud_Probabilities"] = cp + ds["DQF"] = _dqf_var(n, row, col, good=0) + return {"ds": ds, "row": row, "col": col, "expected_prob": 0.8} + + +# --------------------------------------------------------------------------- +# Fixture 3 — DSR int16 with _Unsigned='true' (uint16 reinterpretation) +# --------------------------------------------------------------------------- +@pytest.fixture +def ds_dsr_unsigned() -> dict[str, Any]: + """DSR stored int16 + _Unsigned='true' whose raw value is negative-as-signed. + + raw int16 = -31738 reinterprets to uint16 33798. With scale=0.02 that is + ~675 W/m^2 (positive). Without the reinterpretation, -31738*0.02 = ~-635 + (clearly wrong). The fixture proves the DSR sign fix survives. + """ + n = 5 + ds, row, col = _base_abi_dataset(n) + scale = 0.02 + offset = 0.0 + raw = np.zeros((n, n), dtype=np.int16) + raw[row, col] = -31738 # uint16 == 33798 + dsr = xr.DataArray( + raw, + dims=("y", "x"), + attrs={ + "scale_factor": scale, + "add_offset": offset, + "units": "W m-2", + "_Unsigned": "true", + "_FillValue": np.int16(-1), # == uint16 65535 + "valid_range": np.array([0, 50000], dtype=np.uint16), + }, + ) + ds["DSR"] = dsr + ds["DQF"] = _dqf_var(n, row, col, good=0) + return {"ds": ds, "row": row, "col": col, "expected_positive": True} + + +# --------------------------------------------------------------------------- +# Fixture 4 — ACM with NO units attribute +# --------------------------------------------------------------------------- +@pytest.fixture +def ds_acm_no_units() -> dict[str, Any]: + """ACM 4-level cloud mask var carrying NO ``units`` attr (reads as '').""" + n = 5 + ds, row, col = _base_abi_dataset(n) + raw = np.zeros((n, n), dtype=np.int8) + raw[row, col] = 3 # "cloudy" category + acm = xr.DataArray( + raw, + dims=("y", "x"), + attrs={ + # deliberately NO "units" key + "_FillValue": np.int8(-1), + }, + ) + ds["ACM"] = acm + ds["DQF"] = _dqf_var(n, row, col, good=0) + return {"ds": ds, "row": row, "col": col, "expected_units": ""} + + +# --------------------------------------------------------------------------- +# Fixture 5 — DSRF dual-projection: goes16 lat/lon grid + goes19 ABI grid +# --------------------------------------------------------------------------- +@pytest.fixture +def ds_dsrf_latlon() -> dict[str, Any]: + """goes16-style DSRF on a regular lat/lon grid (goes_lat_lon_projection).""" + n = 5 + # lat/lon grids straddling the station so it lands at a center cell. + lat_center = STATION_LAT + lon_center = STATION_LON + step = 0.5 + lat_vals = (lat_center + (np.arange(n) - n // 2) * step).astype(np.float64) + lon_vals = (lon_center + (np.arange(n) - n // 2) * step).astype(np.float64) + # store packed (scale_factor/add_offset identity for simplicity) + lat = xr.DataArray( + lat_vals, + dims=("lat",), + attrs={"scale_factor": 1.0, "add_offset": 0.0, "units": "degrees_north"}, + ) + lon = xr.DataArray( + lon_vals, + dims=("lon",), + attrs={"scale_factor": 1.0, "add_offset": 0.0, "units": "degrees_east"}, + ) + proj = xr.DataArray( + np.int8(-127), + attrs={ + "grid_mapping_name": "latitude_longitude", + "semi_major_axis": _R_EQ, + "semi_minor_axis": _R_POL, + }, + ) + extent = xr.DataArray( + np.int8(0), + attrs={"geospatial_lon_nadir": NADIR_LON}, + ) + row = col = n // 2 + raw = np.zeros((n, n), dtype=np.int16) + raw[row, col] = round(675.0 / 0.05) + dsr = xr.DataArray( + raw, + dims=("lat", "lon"), + attrs={ + "scale_factor": 0.05, + "add_offset": 0.0, + "units": "W m-2", + "_FillValue": np.int16(-1), + }, + ) + ds = xr.Dataset( + data_vars={ + "goes_lat_lon_projection": proj, + "geospatial_lat_lon_extent": extent, + "DSR": dsr, + "DQF": xr.DataArray( + np.where( + (np.arange(n)[:, None] == row) & (np.arange(n)[None, :] == col), + 0, + -1, + ).astype(np.int8), + dims=("lat", "lon"), + ), + }, + coords={"lat": lat, "lon": lon}, + ) + return {"ds": ds, "row": row, "col": col} + + +@pytest.fixture +def ds_dsrf_abi() -> dict[str, Any]: + """goes19-style DSRF on the ABI fixed grid (goes_imager_projection).""" + n = 5 + ds, row, col = _base_abi_dataset(n) + raw = np.zeros((n, n), dtype=np.int16) + raw[row, col] = round(675.0 / 0.05) + dsr = xr.DataArray( + raw, + dims=("y", "x"), + attrs={ + "scale_factor": 0.05, + "add_offset": 0.0, + "units": "W m-2", + "_FillValue": np.int16(-1), + }, + ) + ds["DSR"] = dsr + ds["DQF"] = _dqf_var(n, row, col, good=0) + return {"ds": ds, "row": row, "col": col} + + +# --------------------------------------------------------------------------- +# Fixture 6 — multi-variable with ONE units mismatch (P2-c continue test) +# --------------------------------------------------------------------------- +@pytest.fixture +def ds_units_mismatch_multivar() -> dict[str, Any]: + """ABI dataset with two registered vars; ONE has a wrong ``units`` attr. + + Drives the annotate-never-drop test: the mismatched var must yield a + ``qc_status='suspect'`` row and the OTHER var must still yield a row. + Uses ABI-L2-DSIC (LI valid, CAPE units mangled) — both 2D on the ABI grid. + """ + n = 5 + ds, row, col = _base_abi_dataset(n) + # LI — correct units "K" + li_raw = np.zeros((n, n), dtype=np.int16) + li_raw[row, col] = round((5.0 - 0.0) / 0.01) # ~5 K + ds["LI"] = xr.DataArray( + li_raw, + dims=("y", "x"), + attrs={ + "scale_factor": 0.01, + "add_offset": 0.0, + "units": "K", + "_FillValue": np.int16(-9999), + }, + ) + # CAPE — registry expects "J kg-1" but the file declares "WRONG/units" + cape_raw = np.zeros((n, n), dtype=np.int16) + cape_raw[row, col] = round((1500.0 - 0.0) / 1.0) + ds["CAPE"] = xr.DataArray( + cape_raw, + dims=("y", "x"), + attrs={ + "scale_factor": 1.0, + "add_offset": 0.0, + "units": "WRONG/units", + "_FillValue": np.int16(-9999), + }, + ) + ds["DQF_Overall"] = _dqf_var(n, row, col, good=0) + return {"ds": ds, "row": row, "col": col} + + +# --------------------------------------------------------------------------- +# Fixture 7 — 3D vertical-profile product (LVTPC) with a pressure axis +# --------------------------------------------------------------------------- +@pytest.fixture +def ds_lvtpc_profile() -> dict[str, Any]: + """A 3D Legacy Vertical Temperature Profile (LVT) on the ABI grid. + + Dims ``(y, x, pressure)`` with a small pressure coordinate so the 3D + profile loop in ``_extract_from_dataset`` emits one row per level. + """ + n = 3 + n_levels = 3 + ds, row, col = _base_abi_dataset(n) + pressures = np.array([1000.0, 850.0, 500.0], dtype=np.float64) + ds = ds.assign_coords(pressure=("pressure", pressures)) + raw = np.zeros((n, n, n_levels), dtype=np.int16) + # decode ~250 K per level: scale 0.01, offset 150 => (250-150)/0.01 = 10000 + raw[row, col, :] = 10000 + ds["LVT"] = xr.DataArray( + raw, + dims=("y", "x", "pressure"), + attrs={ + "scale_factor": 0.01, + "add_offset": 150.0, + "units": "K", + "_FillValue": np.int16(-1), + }, + ) + ds["DQF_Overall"] = _dqf_var(n, row, col, good=0) + return { + "ds": ds, + "row": row, + "col": col, + "n_levels": n_levels, + "pressures": pressures, + } diff --git a/packages/weather/tests/test_satellite.py b/packages/weather/tests/test_satellite.py new file mode 100644 index 0000000..f71ce30 --- /dev/null +++ b/packages/weather/tests/test_satellite.py @@ -0,0 +1,611 @@ +"""Public ``satellite()`` fetcher tests (Phase 25 Wave 3 / 25-04). + +Covers Task 1 (cheap validation incl. the D9 ``mirror`` enum, lazy-import +guard incl. ``gcsfs``, ICAO resolve, backend wrap, package layout) and Task 2 +(qc_status annotate-never-drop worst-wins reducer + units-contract->suspect + +DSRF gating). + +ALL transport is mocked — no live network. The synthetic transport returns the +20-key record dicts that ``_goes_extract._build_record`` produces (the same +shape Wave 2 emits), so the orchestration can be exercised without S3/GCS. +""" + +from __future__ import annotations + +import builtins +import sys +import warnings +from datetime import UTC, datetime, timedelta +from typing import Any + +import pytest + +# Plain optional import (NOT a bare top-level ``import pandas``) — under +# coverage's import-tracing on Python 3.14 a bare top-level pandas import +# re-imports numpy and trips "cannot load module more than once per process". +# The try/except mirrors conftest.py / test_satellite_extract.py. pandas is +# always installed in the dev env, but the guard keeps coverage runs clean. +try: + import pandas as pd + + _HAVE_PANDAS = True +except ImportError: # pragma: no cover + pd = None # type: ignore[assignment] + _HAVE_PANDAS = False + +pytestmark = [ + pytest.mark.filterwarnings("ignore::DeprecationWarning"), + pytest.mark.skipif(not _HAVE_PANDAS, reason="satellite fetcher tests require pandas"), +] + +from mostlyright.weather.satellite import satellite # noqa: E402 +from mostlyright.weather.satellite._resolve import _resolve_station_infos # noqa: E402 + + +def _sat_module() -> Any: + """Return the satellite PACKAGE module object. + + ``mostlyright.weather`` re-exports the ``satellite`` FUNCTION under the + ``satellite`` attribute (mirroring how ``obs`` is re-exported), which + shadows the same-named submodule for attribute access. ``sys.modules`` + always holds the package module object regardless of that shadowing. + """ + return sys.modules["mostlyright.weather.satellite"] + + +# --------------------------------------------------------------------------- +# Synthetic transport: build the 20-key record dicts the extractor produces. +# --------------------------------------------------------------------------- +def _record( + *, + station: str = "KNYC", + satellite_id: str = "goes16", + product: str = "ABI-L2-ACMC", + variable: str = "ACM", + scan_start: str = "2024-06-01T18:00:00Z", + scan_end: str = "2024-06-01T18:05:00Z", + pixel_value: float | None = 1.0, + pixel_dqf: int | None = None, + units: str = "", + pressure_level_hpa: float | None = None, + qc_status: str = "clean", + ingested_at: str | None = None, + source_object_key: str = "ABI-L2-ACMC/2024/153/18/OR_ABI-L2-ACMC.nc", +) -> dict[str, Any]: + return { + "station": station, + "satellite": satellite_id, + "product": product, + "variable": variable, + "pressure_level_hpa": pressure_level_hpa, + "scan_start_utc": scan_start, + "scan_end_utc": scan_end, + "delivery": "live", + "source_object_key": source_object_key, + "ingested_at": ingested_at, + "pixel_value": pixel_value, + "pixel_dqf": pixel_dqf, + "pixel_row": 1, + "pixel_col": 1, + "units": units, + "station_lat": 40.7789, + "station_lon": -73.9692, + "sat_lon_used": -75.0, + "qc_status": qc_status, + "as_of_time": None, + } + + +@pytest.fixture +def mock_transport(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + """Patch list_product_keys + extract_pixel on the satellite package. + + Returns a dict capturing the kwargs each transport call received so tests + can assert mirror threading. + """ + # Patching ``list_product_keys``/``extract_pixel`` resolves them via the + # package ``__getattr__`` which imports ``_goes_s3`` (→ boto3/s3fs). In the + # base CI fast-suite (no [satellite] extra) skip the transport-mocking tests + # cleanly; the validation + import-contract tests in this module still run. + pytest.importorskip( + "boto3", reason="satellite transport mocking requires the [satellite] extra" + ) + sat_pkg = _sat_module() + + calls: dict[str, Any] = {"list": [], "extract": [], "records": [_record()]} + + def fake_list(satellite, product, day, utc_hours, *, mirror="aws", **kw): + calls["list"].append( + {"satellite": satellite, "product": product, "day": day, "mirror": mirror} + ) + return [("ABI-L2-ACMC/2024/153/18/OR_ABI-L2-ACMC.nc", 500_000)] + + def fake_extract(s3_key, bucket, product, station, *, satellite, size, mirror="aws", **kw): + calls["extract"].append( + {"s3_key": s3_key, "product": product, "satellite": satellite, "mirror": mirror} + ) + return list(calls["records"]) + + monkeypatch.setattr(sat_pkg, "list_product_keys", fake_list) + monkeypatch.setattr(sat_pkg, "extract_pixel", fake_extract) + return calls + + +def _kw(**overrides: Any) -> dict[str, Any]: + base = { + "station": "KNYC", + "satellite": "goes16", + "product": "ABI-L2-ACMC", + "start": datetime(2024, 6, 1, tzinfo=UTC), + "end": datetime(2024, 6, 1, tzinfo=UTC), + } + base.update(overrides) + return base + + +# --------------------------------------------------------------------------- +# Cheap validation BEFORE any I/O (works without the extra). +# --------------------------------------------------------------------------- +def test_unknown_product_raises_value_error_before_io() -> None: + with pytest.raises(ValueError, match="product"): + satellite(**_kw(product="ABI-L2-NOPE")) + + +def test_unknown_satellite_raises_value_error() -> None: + with pytest.raises(ValueError, match="satellite"): + satellite(**_kw(satellite="goes99")) + + +def test_inverted_dates_raise_value_error() -> None: + with pytest.raises(ValueError, match=r"start|end|order"): + satellite( + **_kw(start=datetime(2024, 6, 2, tzinfo=UTC), end=datetime(2024, 6, 1, tzinfo=UTC)) + ) + + +def test_unknown_mirror_raises_value_error_listing_aws_gcp() -> None: + # D9: mirror enum validated in the cheap-validation block, before I/O and + # before the lazy-import guard. Message lists the supported mirrors. + with pytest.raises(ValueError) as exc: + satellite(**_kw(mirror="azure")) + msg = str(exc.value) + assert "aws" in msg and "gcp" in msg + + +def test_default_and_gcp_mirror_pass_cheap_validation(mock_transport: dict[str, Any]) -> None: + # Both default (aws) and gcp clear cheap validation and run end-to-end. + df_aws = satellite(**_kw()) + df_gcp = satellite(**_kw(mirror="gcp")) + assert isinstance(df_aws, pd.DataFrame) + assert isinstance(df_gcp, pd.DataFrame) + + +# --------------------------------------------------------------------------- +# D9: mirror threaded to the transport. +# --------------------------------------------------------------------------- +def test_mirror_gcp_threaded_to_transport(mock_transport: dict[str, Any]) -> None: + satellite(**_kw(mirror="gcp")) + assert mock_transport["list"], "list_product_keys not called" + assert all(c["mirror"] == "gcp" for c in mock_transport["list"]) + assert mock_transport["extract"], "extract_pixel not called" + assert all(c["mirror"] == "gcp" for c in mock_transport["extract"]) + + +def test_default_mirror_is_aws_threaded_to_transport(mock_transport: dict[str, Any]) -> None: + satellite(**_kw()) + assert all(c["mirror"] == "aws" for c in mock_transport["list"]) + assert all(c["mirror"] == "aws" for c in mock_transport["extract"]) + + +# --------------------------------------------------------------------------- +# Lazy-import guard (covers gcsfs) -> SourceUnavailableError with hint. +# --------------------------------------------------------------------------- +def test_lazy_guard_missing_gcsfs_raises_source_unavailable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from mostlyright.core.exceptions import SourceUnavailableError + + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "gcsfs": + raise ImportError("No module named 'gcsfs'") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + with pytest.raises(SourceUnavailableError) as exc: + satellite(**_kw()) + assert "pip install mostlyrightmd-weather[satellite]" in str(exc.value) + assert exc.value.retryable is False + assert exc.value.source == "satellite.goes16" + + +def test_lazy_guard_missing_core_dep_raises_with_hint(monkeypatch: pytest.MonkeyPatch) -> None: + from mostlyright.core.exceptions import SourceUnavailableError + + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name in {"boto3", "s3fs", "h5netcdf", "xarray"}: + raise ImportError(f"No module named {name!r}") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + with pytest.raises(SourceUnavailableError) as exc: + satellite(**_kw()) + assert "pip install mostlyrightmd-weather[satellite]" in str(exc.value) + assert exc.value.source == "satellite.goes16" + assert exc.value.retryable is False + + +# --------------------------------------------------------------------------- +# Backend kwargs validated up-front. +# --------------------------------------------------------------------------- +def test_invalid_backend_raises_before_network() -> None: + with pytest.raises((ValueError, TypeError)): + satellite(**_kw(backend="nonsense")) + + +def test_invalid_return_type_raises_before_network() -> None: + with pytest.raises((ValueError, TypeError)): + satellite(**_kw(return_type="nonsense")) + + +# --------------------------------------------------------------------------- +# ICAO resolve via StationInfo (alias dedup, unknown skipped-with-warning). +# --------------------------------------------------------------------------- +def test_resolve_station_infos_dedups_aliases_on_icao() -> None: + infos = _resolve_station_infos(["KNYC", "NYC"]) + assert len(infos) == 1 + assert infos[0].icao == "KNYC" + assert infos[0].name + + +def test_resolve_station_infos_skips_unknown_without_raising() -> None: + infos = _resolve_station_infos(["KNYC", "ZZZZ"]) + icaos = {i.icao for i in infos} + assert "KNYC" in icaos + assert "ZZZZ" not in icaos + + +# --------------------------------------------------------------------------- +# Package layout + clean import without the extra. +# --------------------------------------------------------------------------- +def test_satellite_is_a_package_and_reexports() -> None: + sat_pkg = _sat_module() + + assert hasattr(sat_pkg, "satellite") + assert sat_pkg.__file__.endswith("__init__.py") + + +def test_module_imports_without_heavy_deps() -> None: + # Importing the top-level satellite package must NOT import boto3/s3fs/etc. + # Reimport in a subprocess-free way: drop the heavy modules from sys.modules + # is unsafe across tests; instead assert the source has no top-level heavy + # import by checking the imported module's namespace lacks them. + sat_pkg = _sat_module() + + # The package module itself must not bind boto3/s3fs/gcsfs at module scope. + assert "boto3" not in vars(sat_pkg) + assert "s3fs" not in vars(sat_pkg) + assert "gcsfs" not in vars(sat_pkg) + + +def test_weather_package_reexports_satellite() -> None: + import mostlyright.weather as weather_pkg + + assert hasattr(weather_pkg, "satellite") + + +def test_module_getattr_rejects_unknown_attribute() -> None: + sat_pkg = _sat_module() + + with pytest.raises(AttributeError): + _ = sat_pkg.definitely_not_an_attribute + + +def test_transport_names_resolvable_via_getattr() -> None: + # The lazy transport handles are reachable as package attributes so a + # monkeypatch / reader can replace them. + pytest.importorskip( + "boto3", reason="resolves the satellite transport — requires the [satellite] extra" + ) + sat_pkg = _sat_module() + + assert callable(sat_pkg.list_product_keys) + assert callable(sat_pkg.extract_pixel) + + +def test_empty_result_when_no_stations_resolve(mock_transport: dict[str, Any]) -> None: + # An all-unknown station list resolves to no infos -> empty frame, no I/O, + # but still carries the source-identity attr. + df = satellite(**_kw(station=["ZZZZ"])) + assert df.empty + assert df.attrs["source"] == "noaa_goes" + assert mock_transport["list"] == [] + + +def test_variable_filter_keeps_only_requested(mock_transport: dict[str, Any]) -> None: + mock_transport["records"] = [ + _record(variable="ACM"), + _record( + variable="BCM", + units="1", + scan_start="2024-06-01T18:10:00Z", + scan_end="2024-06-01T18:15:00Z", + ), + ] + df = satellite(**_kw(variable="ACM")) + assert (df["variable"] == "ACM").all() + assert len(df) == 1 + + +def test_wrapper_return_type_routes_through_wrap(mock_transport: dict[str, Any]) -> None: + # return_type="wrapper" exercises _maybe_wrap_satellite's non-default path. + result = satellite(**_kw(return_type="wrapper")) + # The wrapper exposes the source identity it was built with. + assert getattr(result, "source", "noaa_goes") == "noaa_goes" or result is not None + + +# =========================================================================== +# Task 2 — qc_status worst-wins reducer + DSRF gating +# =========================================================================== +def test_clean_record_reduces_to_clean(mock_transport: dict[str, Any]) -> None: + mock_transport["records"] = [_record(units="", pixel_value=1.0)] + df = satellite(**_kw()) + assert (df["qc_status"] == "clean").all() + + +def test_warning_finding_reduces_to_flagged(mock_transport: dict[str, Any]) -> None: + # A units mismatch is a warning-class disposition -> flagged. Use a record + # whose qc_status arrived "clean" so the reducer (not the upstream verdict) + # drives the result; the validate-finding's warning maps to flagged. + mock_transport["records"] = [_record(units="bogus_units", pixel_value=1.0, qc_status="clean")] + df = satellite(**_kw()) + assert (df["qc_status"] == "flagged").all() + + +def test_error_finding_reduces_to_suspect(mock_transport: dict[str, Any]) -> None: + # A physics-bounds violation is error-class -> suspect (severity inversion). + mock_transport["records"] = [_record(pixel_value=999999.0, qc_status="clean")] + df = satellite(**_kw()) + assert (df["qc_status"] == "suspect").all() + + +def test_worst_wins_when_warning_and_error_present(mock_transport: dict[str, Any]) -> None: + # units mismatch (warning) + physics violation (error) -> suspect. + mock_transport["records"] = [_record(units="bogus", pixel_value=999999.0, qc_status="clean")] + df = satellite(**_kw()) + assert (df["qc_status"] == "suspect").all() + + +def test_upstream_units_suspect_carries_through(mock_transport: dict[str, Any]) -> None: + # P2-c upstream: extractor already marked the row suspect for a per-variable + # units mismatch; the reducer keeps it suspect. Other variables still rows. + mock_transport["records"] = [ + _record(variable="ACM", units="bogus", qc_status="suspect", pixel_value=1.0), + _record( + variable="BCM", + units="1", + qc_status="clean", + pixel_value=1.0, + scan_start="2024-06-01T18:10:00Z", + scan_end="2024-06-01T18:15:00Z", + ), + ] + df = satellite(**_kw()) + assert len(df) == 2 # no variable dropped + suspects = df[df["qc_status"] == "suspect"] + assert len(suspects) == 1 + + +def test_units_contract_error_at_boundary_becomes_suspect(monkeypatch: pytest.MonkeyPatch) -> None: + # P2-c defensive: if a UnitsContractError DOES propagate from the extractor, + # it is caught at the boundary and converted to a qc_status=suspect row — + # NOT dropped, NOT raised out of satellite(). + pytest.importorskip( + "boto3", reason="exercises the satellite transport — requires the [satellite] extra" + ) + sat_pkg = _sat_module() + from mostlyright.core.exceptions import UnitsContractError + + def fake_list(*a, **k): + return [("ABI-L2-ACMC/2024/153/18/OR_ABI-L2-ACMC.nc", 500_000)] + + def fake_extract(*a, **k): + raise UnitsContractError("units mismatch unrecoverable") + + monkeypatch.setattr(sat_pkg, "list_product_keys", fake_list) + monkeypatch.setattr(sat_pkg, "extract_pixel", fake_extract) + df = satellite(**_kw()) + # The boundary must not raise; it must emit a suspect row. + assert len(df) >= 1 + assert (df["qc_status"] == "suspect").any() + + +def test_no_row_ever_dropped(mock_transport: dict[str, Any]) -> None: + recs = [ + _record(variable="ACM", scan_start="2024-06-01T18:00:00Z", scan_end="2024-06-01T18:05:00Z"), + _record( + variable="BCM", + units="1", + pixel_value=999999.0, + scan_start="2024-06-01T18:10:00Z", + scan_end="2024-06-01T18:15:00Z", + ), + _record( + variable="ACM", + units="bad", + scan_start="2024-06-01T18:20:00Z", + scan_end="2024-06-01T18:25:00Z", + ), + ] + mock_transport["records"] = recs + df = satellite(**_kw()) + assert len(df) == len(recs) + + +def test_fillvalue_none_stays_clean(mock_transport: dict[str, Any]) -> None: + mock_transport["records"] = [_record(pixel_value=None, qc_status="clean")] + df = satellite(**_kw()) + assert (df["qc_status"] == "clean").all() + + +def test_dsrf_emits_one_time_warning(monkeypatch: pytest.MonkeyPatch) -> None: + pytest.importorskip( + "boto3", reason="exercises the satellite transport — requires the [satellite] extra" + ) + sat_pkg = _sat_module() + + # Reset the module-level dedup flag so the warning fires in this process. + monkeypatch.setattr(sat_pkg, "_DSRF_WARNED", False, raising=False) + + def fake_list(*a, **k): + return [] + + def fake_extract(*a, **k): + return [] + + monkeypatch.setattr(sat_pkg, "list_product_keys", fake_list) + monkeypatch.setattr(sat_pkg, "extract_pixel", fake_extract) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + satellite(**_kw(product="ABI-L2-DSRF")) + dsrf_warnings = [w for w in caught if "backfill" in str(w.message).lower()] + assert len(dsrf_warnings) >= 1 + + # Second call in the same process does not re-warn (dedup). + with warnings.catch_warnings(record=True) as caught2: + warnings.simplefilter("always") + satellite(**_kw(product="ABI-L2-DSRF")) + dsrf_warnings2 = [w for w in caught2 if "backfill" in str(w.message).lower()] + assert len(dsrf_warnings2) == 0 + + +def test_non_dsrf_product_never_warns(mock_transport: dict[str, Any]) -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + satellite(**_kw(product="ABI-L2-ACMC")) + dsrf_warnings = [w for w in caught if "backfill" in str(w.message).lower()] + assert len(dsrf_warnings) == 0 + + +# =========================================================================== +# P2-1 — event-time window: drop scans whose scan_start_utc is OUTSIDE the +# requested [start, end] window. ``_days_in_range`` widens the fetch to whole +# UTC days, so a sub-day window must filter the emitted rows back down. +# =========================================================================== +def test_subday_window_drops_out_of_window_scans(mock_transport: dict[str, Any]) -> None: + """A sub-day window returns ONLY scans whose scan_start_utc is in-window.""" + mock_transport["records"] = [ + _record( # 12:30 — INSIDE [12:00, 13:00] + variable="ACM", + scan_start="2024-06-01T12:30:00Z", + scan_end="2024-06-01T12:35:00Z", + ), + _record( # 18:00 — OUTSIDE the window (but same UTC day) + variable="ACM", + scan_start="2024-06-01T18:00:00Z", + scan_end="2024-06-01T18:05:00Z", + ), + ] + df = satellite( + **_kw( + start=datetime(2024, 6, 1, 12, 0, tzinfo=UTC), + end=datetime(2024, 6, 1, 13, 0, tzinfo=UTC), + ) + ) + assert len(df) == 1 + assert (df["scan_start_utc"] == "2024-06-01T12:30:00Z").all() + # No out-of-window scan leaks through. + assert "2024-06-01T18:00:00Z" not in set(df["scan_start_utc"]) + + +def test_window_boundaries_are_inclusive(mock_transport: dict[str, Any]) -> None: + """A scan exactly at ``start`` or ``end`` is kept (inclusive bounds).""" + mock_transport["records"] = [ + _record(variable="ACM", scan_start="2024-06-01T12:00:00Z"), # == start + _record( + variable="BCM", + units="1", + scan_start="2024-06-01T13:00:00Z", # == end + scan_end="2024-06-01T13:05:00Z", + ), + ] + df = satellite( + **_kw( + start=datetime(2024, 6, 1, 12, 0, tzinfo=UTC), + end=datetime(2024, 6, 1, 13, 0, tzinfo=UTC), + ) + ) + assert len(df) == 2 + + +def test_date_granular_window_keeps_whole_day_scans(mock_transport: dict[str, Any]) -> None: + """A midnight (date-granular) start/end keeps every scan on that whole day.""" + mock_transport["records"] = [ + _record(variable="ACM", scan_start="2024-06-01T00:00:00Z"), + _record( + variable="BCM", + units="1", + scan_start="2024-06-01T12:30:00Z", + scan_end="2024-06-01T12:35:00Z", + ), + _record( + variable="ACM", + scan_start="2024-06-01T23:59:00Z", + scan_end="2024-06-01T23:59:30Z", + ), + ] + # start == end == midnight -> whole-day window (the documented date-granular + # behavior preserved by _days_in_range), so all three scans are in-range. + df = satellite( + **_kw( + start=datetime(2024, 6, 1, tzinfo=UTC), + end=datetime(2024, 6, 1, tzinfo=UTC), + ) + ) + assert len(df) == 3 + + +# =========================================================================== +# P2-4 — satellite frames stamp df.attrs['retrieved_at'] (mirrors forecast_nwp) +# so return_type='wrapper' uses the REAL fetch time, not a synthetic now(). +# =========================================================================== +def test_dataframe_stamps_retrieved_at_attr(mock_transport: dict[str, Any]) -> None: + """The returned frame carries a tz-aware UTC df.attrs['retrieved_at'].""" + df = satellite(**_kw()) + assert "retrieved_at" in df.attrs + ra = df.attrs["retrieved_at"] + assert isinstance(ra, datetime) + assert ra.tzinfo is not None + assert ra.utcoffset() == timedelta(0) # UTC + # The attr equals the per-row retrieved_at (same fetch timestamp), not a + # fresh now() generated later in the call. + row_ra = pd.Timestamp(df["retrieved_at"].iloc[0]).to_pydatetime() + assert ra == row_ra + + +def test_wrapper_return_type_uses_real_retrieved_at( + mock_transport: dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> None: + """return_type='wrapper' threads the REAL df.attrs['retrieved_at'] into wrap_result.""" + captured: dict[str, Any] = {} + + from mostlyright.core import _backend_dispatch + + real_wrap = _backend_dispatch.wrap_result + + def spy_wrap(df, *, retrieved_at=None, **kw): + captured["retrieved_at"] = retrieved_at + return real_wrap(df, retrieved_at=retrieved_at, **kw) + + monkeypatch.setattr(_backend_dispatch, "wrap_result", spy_wrap) + + satellite(**_kw(return_type="wrapper")) + # The wrapper received the real fetch timestamp from df.attrs, not None + # (which would force wrap_result to fabricate a fresh datetime.now()). + assert captured["retrieved_at"] is not None + assert isinstance(captured["retrieved_at"], datetime) + assert captured["retrieved_at"].tzinfo is not None diff --git a/packages/weather/tests/test_satellite_backfill.py b/packages/weather/tests/test_satellite_backfill.py new file mode 100644 index 0000000..543920b --- /dev/null +++ b/packages/weather/tests/test_satellite_backfill.py @@ -0,0 +1,1341 @@ +"""Tests for the GOES ABI L2 fleet backfill CLI (Phase 25 Wave 4 / 25-05). + +Covers the backfill orchestrator (per-(satellite,year,month) slices, direct +``write_satellite_cache`` atomic write — NO staging, NO R2 — the Thread/Process +executor split, the D9 ``--mirror`` thread-through, and the FIX-2 probe-derived +named constants ``_GOES_S3_RATE_HZ`` / ``_DEFAULT_MAX_WORKERS``), the crash-safe +resume layer (fsync durability + ``.bak`` fallback + key/value validation + +single-writer lock), and the argparse CLI. All transport is mocked so the suite +is network-free; the live throughput probe is ``@pytest.mark.live`` (excluded +from CI) and lives in ``test_satellite_probe.py``. +""" + +from __future__ import annotations + +import json +import os +import pickle +from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor +from datetime import date +from pathlib import Path +from unittest import mock + +import pytest +from mostlyright._internal._stations import StationInfo + +# ``_backfill`` imports the transport (_goes_s3 → boto3/s3fs) at module scope, so +# it requires the [satellite] optional extra. In the base CI fast-suite (no +# extra) skip this whole module cleanly — the dedicated satellite-coverage lane +# installs the extra and runs these. Mirrors test_satellite_extract.py. +try: + from mostlyright.weather.satellite import _backfill + + _HAVE_SATELLITE_DEPS = True +except ImportError: # pragma: no cover - exercised only without the extra + _backfill = None # type: ignore[assignment] + _HAVE_SATELLITE_DEPS = False + +pytestmark = pytest.mark.skipif( + not _HAVE_SATELLITE_DEPS, + reason="GOES backfill tests require the [satellite] optional extra (boto3/s3fs)", +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- +@pytest.fixture +def knyc() -> StationInfo: + return StationInfo( + code="NYC", + ghcnh_id="USW00094728", + icao="KNYC", + name="New York Central Park", + tz="America/New_York", + latitude=40.7790, + longitude=-73.9690, + country="US", + ) + + +def _fake_record(scan_start: str = "2024-06-15T18:00:00Z") -> dict: + return { + "station": "KNYC", + "satellite": "goes16", + "product": "ABI-L2-ACMC", + "variable": "BCM", + "pressure_level_hpa": None, + "scan_start_utc": scan_start, + "scan_end_utc": scan_start, + "source_object_key": "ABI-L2-ACMC/2024/167/18/file.nc", + "ingested_at": None, + "pixel_value": 1.0, + "pixel_dqf": 0, + "pixel_row": 10, + "pixel_col": 20, + "units": "1", + "station_lat": 40.779, + "station_lon": -73.969, + "sat_lon_used": -75.0, + "delivery": "live", + } + + +# --------------------------------------------------------------------------- +# Task 1: probe-derived named constants (FIX-2 provenance lock) +# --------------------------------------------------------------------------- +class TestProbeDerivedConstants: + def test_rate_hz_is_named_module_constant(self) -> None: + assert isinstance(_backfill._GOES_S3_RATE_HZ, float) + assert _backfill._GOES_S3_RATE_HZ > 0 + + def test_default_max_workers_is_named_module_constant(self) -> None: + assert isinstance(_backfill._DEFAULT_MAX_WORKERS, int) + assert _backfill._DEFAULT_MAX_WORKERS >= 1 + + def test_constants_have_source_limits_citation_comment(self) -> None: + """FIX-2: each constant carries a SOURCE-LIMITS.md provenance comment + (mirroring NOMADS_CONCURRENCY_CAP). Assert the source text cites it.""" + src = Path(_backfill.__file__).read_text() + # The provenance comment must name SOURCE-LIMITS.md for both constants. + rate_idx = src.index("_GOES_S3_RATE_HZ") + workers_idx = src.index("_DEFAULT_MAX_WORKERS") + # A SOURCE-LIMITS citation must appear in the module (provenance). + assert "SOURCE-LIMITS" in src + # The probe re-measure command must be documented for max_workers. + assert "python -m mostlyright.weather.satellite probe" in src + assert rate_idx >= 0 and workers_idx >= 0 + + def test_provenance_lock_matches_source_limits(self) -> None: + """FIX-2 provenance lock: the shipped constants MATCH / are floored at + the SOURCE-LIMITS.md satellite entry's derive_rate_cap / derive_max_workers. + + The probe (Task 3) writes the satellite section; until the live probe + runs the recorded values ARE the conservative-pending values the + constants are seeded with. We read them back with the probe's reader + helper and assert the shipped constants do not EXCEED the record. + """ + from mostlyright.weather.satellite._probe import ( + read_source_limits_satellite, + ) + + # Use the in-repo SOURCE-LIMITS.md seeded by Task 3. + recorded = read_source_limits_satellite(_backfill._SOURCE_LIMITS_PATH) + assert recorded is not None, ( + "SOURCE-LIMITS.md must carry a satellite section (Task 3 seeds the " + "conservative-pending values)" + ) + # shipped rate cap <= recorded (lower is more conservative = safe) + assert recorded["rate_hz"] + 1e-9 >= _backfill._GOES_S3_RATE_HZ + # shipped max_workers <= recorded (floored at the recorded knee) + assert recorded["max_workers"] >= _backfill._DEFAULT_MAX_WORKERS + + +# --------------------------------------------------------------------------- +# Task 1: direct atomic write (no staging / no R2) +# --------------------------------------------------------------------------- +class TestDirectAtomicWrite: + def test_slice_writes_via_write_satellite_cache(self, knyc, tmp_path) -> None: + def _list_one_day(satellite, product, day, hours, *, mirror="aws"): + # Only day 167 (2024-06-15) has a scan; other days are empty gaps. + if day == date(2024, 6, 15): + return [("ABI-L2-ACMC/2024/167/18/file.nc", 1024)] + return [] + + with ( + mock.patch.object(_backfill, "list_product_keys", _list_one_day), + mock.patch.object(_backfill, "extract_pixel") as m_extract, + mock.patch.object(_backfill, "write_satellite_cache") as m_write, + ): + m_extract.return_value = [_fake_record()] + res = _backfill.backfill_goes_satellite( + station=knyc, + satellite="goes16", + product="ABI-L2-ACMC", + year=2024, + month=6, + out=tmp_path, + ) + assert m_write.called + # exactly the (sat, product, station, year, month) partition. The 5 + # identity fields + rows are passed positionally; cache_root (P2-1) is a + # keyword. Bind by position for the identity fields. + args = m_write.call_args.args + kwargs = m_write.call_args.kwargs + sat, prod, sta, yr, mo = args[0], args[1], args[2], args[3], args[4] + assert sat == "goes16" + assert prod == "ABI-L2-ACMC" + assert sta == "KNYC" + assert yr == 2024 + assert mo == 6 + # P2-1: --out is threaded as the cache root so the partition lands there. + assert kwargs.get("cache_root") == tmp_path + assert res.rows_written == 1 + + def test_no_staging_no_r2_symbols_in_module(self) -> None: + src = Path(_backfill.__file__).read_text() + assert "write_satellite_staging" not in src + assert "merge_satellite_staging" not in src + assert "R2" not in src + assert "to_R2" not in src + + def test_no_ingest_import(self) -> None: + src = Path(_backfill.__file__).read_text() + assert "import ingest" not in src + assert "from ingest" not in src + + +# --------------------------------------------------------------------------- +# Task 1: D9 mirror thread-through (transport-only) +# --------------------------------------------------------------------------- +class TestMirrorThreadThrough: + def test_backfill_threads_mirror_gcp(self, knyc, tmp_path) -> None: + with ( + mock.patch.object(_backfill, "list_product_keys") as m_list, + mock.patch.object(_backfill, "extract_pixel") as m_extract, + mock.patch.object(_backfill, "write_satellite_cache"), + ): + m_list.return_value = [("k.nc", 1024)] + m_extract.return_value = [_fake_record()] + _backfill.backfill_goes_satellite( + station=knyc, + satellite="goes16", + product="ABI-L2-ACMC", + year=2024, + month=6, + out=tmp_path, + mirror="gcp", + ) + assert m_list.call_args.kwargs["mirror"] == "gcp" + assert m_extract.call_args.kwargs["mirror"] == "gcp" + + def test_backfill_default_mirror_is_aws(self, knyc, tmp_path) -> None: + with ( + mock.patch.object(_backfill, "list_product_keys") as m_list, + mock.patch.object(_backfill, "extract_pixel") as m_extract, + mock.patch.object(_backfill, "write_satellite_cache"), + ): + m_list.return_value = [("k.nc", 1024)] + m_extract.return_value = [_fake_record()] + _backfill.backfill_goes_satellite( + station=knyc, + satellite="goes16", + product="ABI-L2-ACMC", + year=2024, + month=6, + out=tmp_path, + ) + assert m_list.call_args.kwargs["mirror"] == "aws" + assert m_extract.call_args.kwargs["mirror"] == "aws" + + def test_cache_partition_is_mirror_invariant(self, knyc, tmp_path) -> None: + """The partition write args are identical regardless of mirror (D9).""" + captured: list[dict] = [] + + def _capture(satellite, product, station, year, month, rows, *, cache_root=None): + captured.append( + { + "satellite": satellite, + "product": product, + "station": station, + "year": year, + "month": month, + "cache_root": cache_root, + } + ) + + for mir in ("aws", "gcp"): + with ( + mock.patch.object(_backfill, "list_product_keys") as m_list, + mock.patch.object(_backfill, "extract_pixel") as m_extract, + mock.patch.object(_backfill, "write_satellite_cache", _capture), + ): + m_list.return_value = [("k.nc", 1024)] + m_extract.return_value = [_fake_record()] + _backfill.backfill_goes_satellite( + station=knyc, + satellite="goes16", + product="ABI-L2-ACMC", + year=2024, + month=6, + out=tmp_path, + mirror=mir, + ) + assert captured[0] == captured[1] + + +# --------------------------------------------------------------------------- +# Task 1: available_since clamp +# --------------------------------------------------------------------------- +class TestAvailableSinceClamp: + def test_slice_before_availability_is_skipped(self, knyc, tmp_path) -> None: + # goes19 available_since = 2024-11-15; a 2024-06 slice is pre-availability. + with ( + mock.patch.object(_backfill, "list_product_keys") as m_list, + mock.patch.object(_backfill, "extract_pixel") as m_extract, + mock.patch.object(_backfill, "write_satellite_cache") as m_write, + ): + res = _backfill.backfill_goes_satellite( + station=knyc, + satellite="goes19", + product="ABI-L2-ACMC", + year=2024, + month=6, + out=tmp_path, + ) + assert not m_list.called + assert not m_extract.called + assert not m_write.called + assert res.rows_written == 0 + assert res.skipped_pre_availability is True + + +# --------------------------------------------------------------------------- +# Task 1: bulk slices + ICAO resolve +# --------------------------------------------------------------------------- +class TestBulkSlices: + def test_bulk_resolves_stations_and_iterates_slices(self, tmp_path) -> None: + calls: list[tuple] = [] + + def _fake_slice(*, station, satellite, product, year, month, out, mirror, **kw): + calls.append((station.icao, satellite, product, year, month)) + return _backfill.ProductBackfillResult( + station=station.icao, + satellite=satellite, + product=product, + year=year, + month=month, + scans_fetched=0, + rows_written=0, + duration_s=0.0, + errors=(), + skipped_pre_availability=False, + ) + + with mock.patch.object(_backfill, "backfill_goes_satellite", _fake_slice): + res = _backfill.bulk_backfill( + satellites=["goes16"], + products=["ABI-L2-ACMC"], + stations=["KNYC"], + year_start=2024, + year_end=2024, + out=tmp_path, + resume=False, + ) + # 12 months of 2024 for one (sat, product, station) + assert len(calls) == 12 + assert all(c[0] == "KNYC" for c in calls) + assert {c[3] for c in calls} == {2024} + assert {c[4] for c in calls} == set(range(1, 13)) + assert res is not None + + def test_bulk_threads_mirror_gcp(self, tmp_path) -> None: + seen: list[str] = [] + + def _fake_slice(*, mirror, **kw): + seen.append(mirror) + return _backfill.ProductBackfillResult( + station=kw["station"].icao, + satellite=kw["satellite"], + product=kw["product"], + year=kw["year"], + month=kw["month"], + scans_fetched=0, + rows_written=0, + duration_s=0.0, + errors=(), + skipped_pre_availability=False, + ) + + with mock.patch.object(_backfill, "backfill_goes_satellite", _fake_slice): + _backfill.bulk_backfill( + satellites=["goes16"], + products=["ABI-L2-ACMC"], + stations=["KNYC"], + year_start=2024, + year_end=2024, + out=tmp_path, + resume=False, + mirror="gcp", + ) + assert seen and all(m == "gcp" for m in seen) + + +# --------------------------------------------------------------------------- +# Task 1: executor split (thread / process) +# --------------------------------------------------------------------------- +class TestExecutorSplit: + def test_thread_executor_default(self, tmp_path) -> None: + captured: list[type] = [] + orig = _backfill._make_executor + + def _spy(executor: str, max_workers: int): + ex = orig(executor, max_workers) + captured.append(type(ex)) + return ex + + with ( + mock.patch.object(_backfill, "backfill_goes_satellite") as m_slice, + mock.patch.object(_backfill, "_make_executor", _spy), + ): + m_slice.return_value = _backfill.ProductBackfillResult( + station="KNYC", + satellite="goes16", + product="ABI-L2-ACMC", + year=2024, + month=1, + scans_fetched=0, + rows_written=0, + duration_s=0.0, + errors=(), + skipped_pre_availability=False, + ) + _backfill.bulk_backfill( + satellites=["goes16"], + products=["ABI-L2-ACMC"], + stations=["KNYC"], + year_start=2024, + year_end=2024, + out=tmp_path, + resume=False, + executor="thread", + ) + assert ThreadPoolExecutor in captured + + def test_process_executor_for_dsrf(self, tmp_path) -> None: + captured: list[type] = [] + orig = _backfill._make_executor + + def _spy(executor: str, max_workers: int): + ex = orig(executor, max_workers) + captured.append(type(ex)) + return ex + + with ( + mock.patch.object(_backfill, "backfill_goes_satellite") as m_slice, + mock.patch.object(_backfill, "_make_executor", _spy), + ): + m_slice.return_value = _backfill.ProductBackfillResult( + station="KNYC", + satellite="goes16", + product="ABI-L2-DSRF", + year=2024, + month=1, + scans_fetched=0, + rows_written=0, + duration_s=0.0, + errors=(), + skipped_pre_availability=False, + ) + _backfill.bulk_backfill( + satellites=["goes16"], + products=["ABI-L2-DSRF"], + stations=["KNYC"], + year_start=2024, + year_end=2024, + out=tmp_path, + resume=False, + executor="process", + ) + assert ProcessPoolExecutor in captured + + def test_max_workers_threaded_and_default_is_constant(self, tmp_path) -> None: + seen: dict[str, int] = {} + orig = _backfill._make_executor + + def _spy(executor: str, max_workers: int): + seen["max_workers"] = max_workers + return orig(executor, max_workers) + + with ( + mock.patch.object(_backfill, "backfill_goes_satellite") as m_slice, + mock.patch.object(_backfill, "_make_executor", _spy), + ): + m_slice.return_value = _backfill.ProductBackfillResult( + station="KNYC", + satellite="goes16", + product="ABI-L2-ACMC", + year=2024, + month=1, + scans_fetched=0, + rows_written=0, + duration_s=0.0, + errors=(), + skipped_pre_availability=False, + ) + _backfill.bulk_backfill( + satellites=["goes16"], + products=["ABI-L2-ACMC"], + stations=["KNYC"], + year_start=2024, + year_end=2024, + out=tmp_path, + resume=False, + ) + assert seen["max_workers"] == _backfill._DEFAULT_MAX_WORKERS + + +# --------------------------------------------------------------------------- +# Task 2: resume layer — skip / no-resume / atomic-barrier / .bak / validation +# --------------------------------------------------------------------------- +def _stub_slice(**kw): + return _backfill.ProductBackfillResult( + station=kw["station"].icao, + satellite=kw["satellite"], + product=kw["product"], + year=kw["year"], + month=kw["month"], + scans_fetched=0, + rows_written=0, + duration_s=0.0, + errors=(), + skipped_pre_availability=False, + ) + + +class TestResumeSkip: + def test_completed_slice_is_skipped_on_resume(self, tmp_path) -> None: + progress = tmp_path / _backfill._PROGRESS_FILENAME + # Pre-seed: every 2024 month for the goes16/ACMC/KNYC slice completed + # EXCEPT 2024-07. Keys carry the FULL slice identity (P1-2). + seeded = { + _backfill._progress_key("goes16", "ABI-L2-ACMC", "KNYC", 2024, m): "completed" + for m in range(1, 13) + if m != 7 + } + _backfill._save_progress(progress, seeded) + + ran: list[int] = [] + + def _slice(**kw): + ran.append(kw["month"]) + return _stub_slice(**kw) + + with mock.patch.object(_backfill, "backfill_goes_satellite", _slice): + res = _backfill.bulk_backfill( + satellites=["goes16"], + products=["ABI-L2-ACMC"], + stations=["KNYC"], + year_start=2024, + year_end=2024, + out=tmp_path, + resume=True, + ) + assert ran == [7] + assert res.slices_skipped_resume == 11 + + def test_no_resume_neither_reads_nor_writes_but_locks(self, tmp_path) -> None: + progress = tmp_path / _backfill._PROGRESS_FILENAME + # Seed a "completed" marker that --no-resume must IGNORE (not read). + _backfill._save_progress(progress, {"goes16_ABI-L2-ACMC_KNYC_2024_01": "completed"}) + before = progress.read_bytes() + lock_seen: list[bool] = [] + orig_acquire = _backfill._acquire_lock + + def _spy_acquire(p): + lock_seen.append(True) + return orig_acquire(p) + + ran: list[int] = [] + + def _slice(**kw): + ran.append(kw["month"]) + return _stub_slice(**kw) + + with ( + mock.patch.object(_backfill, "backfill_goes_satellite", _slice), + mock.patch.object(_backfill, "_acquire_lock", _spy_acquire), + ): + _backfill.bulk_backfill( + satellites=["goes16"], + products=["ABI-L2-ACMC"], + stations=["KNYC"], + year_start=2024, + year_end=2024, + out=tmp_path, + resume=False, + ) + # --no-resume STILL acquires the lock ... + assert lock_seen == [True] + # ... runs ALL 12 months (did not read the seeded marker; ThreadPool + # completion order is non-deterministic, so compare as a set) ... + assert sorted(ran) == list(range(1, 13)) + # ... and does NOT rewrite the progress file. + assert progress.read_bytes() == before + + +class TestResumeHardening: + def test_save_progress_atomic_with_barrier(self, tmp_path) -> None: + progress = tmp_path / _backfill._PROGRESS_FILENAME + _backfill._save_progress(progress, {"goes16_ABI-L2-ACMC_KNYC_2024_01": "completed"}) + + with ( + mock.patch("mostlyright.weather.satellite._backfill.os.sync") as m_sync, + mock.patch( + "mostlyright.weather.satellite._backfill.os.fsync", + wraps=os.fsync, + ) as m_fsync, + mock.patch( + "mostlyright.weather.satellite._backfill.os.replace", + wraps=os.replace, + ) as m_replace, + ): + _backfill._save_progress(progress, {"goes16_ABI-L2-ACMC_KNYC_2024_02": "completed"}) + # os.sync() barrier BEFORE the mark. + assert m_sync.called + # tmp fsync + parent-dir fsync (>= 2 fsyncs). + assert m_fsync.call_count >= 2 + # atomic rename. + assert m_replace.called + loaded = _backfill._load_progress(progress) + assert loaded["goes16_ABI-L2-ACMC_KNYC_2024_02"] == "completed" + + def test_bak_snapshot_holds_previous_revision(self, tmp_path) -> None: + progress = tmp_path / _backfill._PROGRESS_FILENAME + bak = progress.with_suffix(progress.suffix + ".bak") + _backfill._save_progress(progress, {"goes16_ABI-L2-ACMC_KNYC_2024_01": "completed"}) + _backfill._save_progress(progress, {"goes16_ABI-L2-ACMC_KNYC_2024_02": "completed"}) + assert bak.exists() + prev = json.loads(bak.read_text()) + assert "goes16_ABI-L2-ACMC_KNYC_2024_01" in prev + + def test_torn_main_falls_back_to_bak(self, tmp_path) -> None: + progress = tmp_path / _backfill._PROGRESS_FILENAME + _backfill._save_progress(progress, {"goes16_ABI-L2-ACMC_KNYC_2024_01": "completed"}) + _backfill._save_progress(progress, {"goes16_ABI-L2-ACMC_KNYC_2024_02": "completed"}) + # Tear the main file; .bak is intact. + progress.write_text("{ this is not json") + loaded = _backfill._load_progress(progress) + # .bak held the prior revision (only month 01). + assert "goes16_ABI-L2-ACMC_KNYC_2024_01" in loaded + + def test_both_torn_raises_loudly(self, tmp_path) -> None: + progress = tmp_path / _backfill._PROGRESS_FILENAME + bak = progress.with_suffix(progress.suffix + ".bak") + progress.write_text("{ torn") + bak.write_text("{ also torn") + with pytest.raises(_backfill.ProgressCorrupt): + _backfill._load_progress(progress) + + def test_invalid_key_rejected(self, tmp_path) -> None: + progress = tmp_path / _backfill._PROGRESS_FILENAME + progress.write_text( + json.dumps({"__version__": 1, "goesXX_ABI-L2-ACMC_KNYC_2024_01": "completed"}) + ) + with pytest.raises(_backfill.ProgressCorrupt): + _backfill._load_progress(progress) + + def test_invalid_value_rejected(self, tmp_path) -> None: + progress = tmp_path / _backfill._PROGRESS_FILENAME + progress.write_text( + json.dumps({"__version__": 1, "goes16_ABI-L2-ACMC_KNYC_2024_01": "PARTIAL"}) + ) + with pytest.raises(_backfill.ProgressCorrupt): + _backfill._load_progress(progress) + + def test_invalid_month_in_key_rejected(self, tmp_path) -> None: + progress = tmp_path / _backfill._PROGRESS_FILENAME + progress.write_text( + json.dumps({"__version__": 1, "goes16_ABI-L2-ACMC_KNYC_2024_13": "completed"}) + ) + with pytest.raises(_backfill.ProgressCorrupt): + _backfill._load_progress(progress) + + +class TestSingleWriterLock: + def test_double_start_raises_lock_busy(self, tmp_path) -> None: + lock = tmp_path / _backfill._PROGRESS_LOCK_FILENAME + _backfill._acquire_lock(lock) + try: + with pytest.raises(_backfill.ProgressLockBusy): + _backfill._acquire_lock(lock) + finally: + _backfill._release_lock(lock) + # Released -> a fresh acquire succeeds. + _backfill._acquire_lock(lock) + _backfill._release_lock(lock) + + def test_lock_carries_pid_and_hostname(self, tmp_path) -> None: + lock = tmp_path / _backfill._PROGRESS_LOCK_FILENAME + _backfill._acquire_lock(lock) + body = lock.read_text() + _backfill._release_lock(lock) + assert "pid=" in body + assert "host=" in body + + def test_lock_released_in_finally_after_success(self, tmp_path) -> None: + lock = tmp_path / _backfill._PROGRESS_LOCK_FILENAME + with mock.patch.object(_backfill, "backfill_goes_satellite", _stub_slice): + _backfill.bulk_backfill( + satellites=["goes16"], + products=["ABI-L2-ACMC"], + stations=["KNYC"], + year_start=2024, + year_end=2024, + out=tmp_path, + resume=False, + ) + assert not lock.exists() + + +class TestFailedNotMarked: + def test_erroring_slice_not_marked_completed(self, tmp_path) -> None: + progress = tmp_path / _backfill._PROGRESS_FILENAME + + def _slice(**kw): + if kw["month"] == 3: + return _backfill.ProductBackfillResult( + station=kw["station"].icao, + satellite=kw["satellite"], + product=kw["product"], + year=kw["year"], + month=kw["month"], + scans_fetched=0, + rows_written=0, + duration_s=0.0, + errors=("list 2024-03-01: boom",), + skipped_pre_availability=False, + ) + return _stub_slice(**kw) + + with mock.patch.object(_backfill, "backfill_goes_satellite", _slice): + _backfill.bulk_backfill( + satellites=["goes16"], + products=["ABI-L2-ACMC"], + stations=["KNYC"], + year_start=2024, + year_end=2024, + out=tmp_path, + resume=True, + ) + loaded = _backfill._load_progress(progress) + # Month 3 errored -> NOT marked; others marked. + assert "goes16_ABI-L2-ACMC_KNYC_2024_03" not in loaded + assert "goes16_ABI-L2-ACMC_KNYC_2024_01" in loaded + + +# --------------------------------------------------------------------------- +# Task 2: argparse CLI (backfill subcommand + --mirror + path hardening) +# --------------------------------------------------------------------------- +class TestBackfillCLI: + def test_backfill_dispatch_parses_args(self, tmp_path) -> None: + from mostlyright.weather.satellite import __main__ as cli + + captured: dict = {} + + def _fake_bulk(**kw): + captured.update(kw) + return _backfill.BulkBackfillResult( + results=(), + total_scans_fetched=0, + total_rows_written=0, + slices_completed=0, + slices_skipped_resume=0, + duration_s=0.0, + ) + + with mock.patch.object(cli, "bulk_backfill", _fake_bulk): + rc = cli.main( + [ + "backfill", + "--satellites", + "goes16,goes19", + "--products", + "ABI-L2-ACMC", + "--stations", + "KNYC", + "--year-start", + "2024", + "--year-end", + "2024", + "--out", + str(tmp_path), + "--max-workers", + "4", + "--resume", + ] + ) + assert rc == 0 + assert captured["satellites"] == ["goes16", "goes19"] + assert captured["products"] == ["ABI-L2-ACMC"] + assert captured["stations"] == ["KNYC"] + assert captured["year_start"] == 2024 + assert captured["year_end"] == 2024 + assert captured["max_workers"] == 4 + assert captured["resume"] is True + + def test_cli_default_mirror_is_aws(self, tmp_path) -> None: + from mostlyright.weather.satellite import __main__ as cli + + captured: dict = {} + + def _fake_bulk(**kw): + captured.update(kw) + return _backfill.BulkBackfillResult( + results=(), + total_scans_fetched=0, + total_rows_written=0, + slices_completed=0, + slices_skipped_resume=0, + duration_s=0.0, + ) + + with mock.patch.object(cli, "bulk_backfill", _fake_bulk): + cli.main( + [ + "backfill", + "--satellites", + "goes16", + "--products", + "ABI-L2-ACMC", + "--stations", + "KNYC", + "--year-start", + "2024", + "--year-end", + "2024", + "--out", + str(tmp_path), + ] + ) + assert captured["mirror"] == "aws" + + def test_cli_mirror_gcp_threaded(self, tmp_path) -> None: + from mostlyright.weather.satellite import __main__ as cli + + captured: dict = {} + + def _fake_bulk(**kw): + captured.update(kw) + return _backfill.BulkBackfillResult( + results=(), + total_scans_fetched=0, + total_rows_written=0, + slices_completed=0, + slices_skipped_resume=0, + duration_s=0.0, + ) + + with mock.patch.object(cli, "bulk_backfill", _fake_bulk): + cli.main( + [ + "backfill", + "--satellites", + "goes16", + "--products", + "ABI-L2-ACMC", + "--stations", + "KNYC", + "--year-start", + "2024", + "--year-end", + "2024", + "--out", + str(tmp_path), + "--mirror", + "gcp", + ] + ) + assert captured["mirror"] == "gcp" + + def test_cli_invalid_mirror_rejected_by_argparse(self, tmp_path) -> None: + from mostlyright.weather.satellite import __main__ as cli + + with pytest.raises(SystemExit): + cli.main( + [ + "backfill", + "--satellites", + "goes16", + "--products", + "ABI-L2-ACMC", + "--stations", + "KNYC", + "--year-start", + "2024", + "--year-end", + "2024", + "--out", + str(tmp_path), + "--mirror", + "azure", + ] + ) + + +# --------------------------------------------------------------------------- +# P1-1: the ProcessPoolExecutor worker + its item must be PICKLABLE. +# +# The 2i nested-closure ``_run`` captured run-wide params and was submitted via +# ``pool.submit(_run, item)``. Under ``executor="process"`` that closure is not +# picklable, so EVERY submit raised PicklingError before any slice ran — the +# documented DSRF process-pool path was dead on arrival. The worker must live at +# module scope and the submitted item must carry everything it needs as a fully +# picklable payload. +# --------------------------------------------------------------------------- +class TestProcessPoolPicklable: + def test_module_level_worker_and_item_round_trip_pickle(self, knyc) -> None: + # The worker callable submitted into the pool must be importable by + # qualified name (module scope), i.e. picklable on its own. + worker = _backfill._run_slice + pickle.loads(pickle.dumps(worker)) + + # A representative submitted item must round-trip through pickle. + item = (knyc, "goes16", "ABI-L2-DSRF", 2024, 6, Path("/tmp/out"), "aws", 8) + restored = pickle.loads(pickle.dumps(item)) + assert restored[0].icao == "KNYC" + assert restored[1:] == ("goes16", "ABI-L2-DSRF", 2024, 6, Path("/tmp/out"), "aws", 8) + + def test_process_executor_runs_end_to_end(self, tmp_path) -> None: + # A real ProcessPoolExecutor submission must execute the worker without a + # PicklingError. The transport is stubbed at module scope (so the child + # process imports a stub-free real fetcher and we assert only that the + # process path completes), and the slice itself is a no-op shape. + res = _backfill.bulk_backfill( + satellites=["goes16"], + products=["ABI-L2-DSRF"], + stations=["KNYC"], + year_start=1999, # entirely before goes16 first-light -> all slices skip I/O + year_end=1999, + out=tmp_path, + resume=False, + executor="process", + max_workers=2, + ) + # 12 months, all pre-availability skipped, but each ran in a child + # process without raising PicklingError. + assert len(res.results) == 12 + assert all(r.skipped_pre_availability for r in res.results) + + +# --------------------------------------------------------------------------- +# P1-2: the resume progress key must encode the FULL slice identity +# (satellite, product, station, year, month). The 2i key dropped product AND +# station, so once one (product, station) slice in a satellite-month was marked +# completed, a resumed run skipped every OTHER (product, station) in that same +# satellite-month though their partitions were never written -> silent data loss. +# --------------------------------------------------------------------------- +class TestProgressKeyIdentity: + def test_key_distinguishes_product(self) -> None: + a = _backfill._progress_key("goes16", "ABI-L2-ACMC", "KNYC", 2024, 6) + b = _backfill._progress_key("goes16", "ABI-L2-LSTC", "KNYC", 2024, 6) + assert a != b + + def test_key_distinguishes_station(self) -> None: + a = _backfill._progress_key("goes16", "ABI-L2-ACMC", "KNYC", 2024, 6) + b = _backfill._progress_key("goes16", "ABI-L2-ACMC", "KAUS", 2024, 6) + assert a != b + + def test_resume_does_not_over_skip_sibling_product(self, tmp_path) -> None: + # Slice A (ACMC) is COMPLETED. Slice B (LSTC) for the SAME sat/year/month + # was never written and MUST still execute on resume. + progress = tmp_path / _backfill._PROGRESS_FILENAME + key_a = _backfill._progress_key("goes16", "ABI-L2-ACMC", "KNYC", 2024, 6) + _backfill._save_progress(progress, {key_a: "completed"}) + + ran: list[tuple[str, int]] = [] + + def _slice(**kw): + ran.append((kw["product"], kw["month"])) + return _stub_slice(**kw) + + with mock.patch.object(_backfill, "backfill_goes_satellite", _slice): + _backfill.bulk_backfill( + satellites=["goes16"], + products=["ABI-L2-ACMC", "ABI-L2-LSTC"], + stations=["KNYC"], + year_start=2024, + year_end=2024, + out=tmp_path, + resume=True, + ) + # ACMC 2024-06 was completed -> skipped. EVERY LSTC month (incl. 06) ran. + assert ("ABI-L2-LSTC", 6) in ran + assert ("ABI-L2-ACMC", 6) not in ran + # No other ACMC month was suppressed by the LSTC completion (or vice versa). + assert sorted(m for (p, m) in ran if p == "ABI-L2-LSTC") == list(range(1, 13)) + assert sorted(m for (p, m) in ran if p == "ABI-L2-ACMC") == [ + m for m in range(1, 13) if m != 6 + ] + + def test_resume_does_not_over_skip_sibling_station(self, tmp_path) -> None: + progress = tmp_path / _backfill._PROGRESS_FILENAME + key_a = _backfill._progress_key("goes16", "ABI-L2-ACMC", "KNYC", 2024, 6) + _backfill._save_progress(progress, {key_a: "completed"}) + + ran: list[tuple[str, int]] = [] + + def _slice(**kw): + ran.append((kw["station"].icao, kw["month"])) + return _stub_slice(**kw) + + with mock.patch.object(_backfill, "backfill_goes_satellite", _slice): + _backfill.bulk_backfill( + satellites=["goes16"], + products=["ABI-L2-ACMC"], + stations=["KNYC", "KAUS"], + year_start=2024, + year_end=2024, + out=tmp_path, + resume=True, + ) + # KNYC 2024-06 completed -> skipped; KAUS 2024-06 (and every KAUS month) ran. + assert ("KAUS", 6) in ran + assert ("KNYC", 6) not in ran + assert sorted(m for (s, m) in ran if s == "KAUS") == list(range(1, 13)) + + +# --------------------------------------------------------------------------- +# P2-1: backfill --out must be HONORED for the parquet write, not just for the +# progress file. The slice's ``write_satellite_cache`` call ignored ``out`` and +# resolved the home/env cache root, so the parquet landed somewhere other than +# ``--out``. The partition must materialize UNDER ``--out``. +# --------------------------------------------------------------------------- +class TestOutHonoredForParquet: + def test_parquet_written_under_out(self, knyc, tmp_path, monkeypatch) -> None: + # Point the DEFAULT cache root somewhere distinct so we can prove the + # write did NOT fall back to it. + default_root = tmp_path / "default_cache" + monkeypatch.setenv("MOSTLYRIGHT_CACHE_DIR", str(default_root)) + out = tmp_path / "explicit_out" + + keys = [("ABI-L2-ACMC/2024/167/18/file.nc", 1234)] + + with ( + mock.patch.object(_backfill, "list_product_keys", return_value=keys), + mock.patch.object(_backfill, "extract_pixel", return_value=[_fake_record()]), + mock.patch.object(_backfill, "_bucket_for", return_value="noaa-goes16"), + ): + _backfill.backfill_goes_satellite( + station=knyc, + satellite="goes16", + product="ABI-L2-ACMC", + year=2024, + month=6, + out=out, + ) + + expected = ( + out / "v1" / "satellite" / "goes16" / "ABI-L2-ACMC" / "KNYC" / "2024" / "06.parquet" + ) + assert expected.exists(), f"parquet not written under --out: {expected}" + # And it must NOT have fallen back to the default/env cache root. + default_partition = ( + default_root + / "v1" + / "satellite" + / "goes16" + / "ABI-L2-ACMC" + / "KNYC" + / "2024" + / "06.parquet" + ) + assert not default_partition.exists() + + def test_cli_no_resume_flag(self, tmp_path) -> None: + from mostlyright.weather.satellite import __main__ as cli + + captured: dict = {} + + def _fake_bulk(**kw): + captured.update(kw) + return _backfill.BulkBackfillResult( + results=(), + total_scans_fetched=0, + total_rows_written=0, + slices_completed=0, + slices_skipped_resume=0, + duration_s=0.0, + ) + + with mock.patch.object(cli, "bulk_backfill", _fake_bulk): + cli.main( + [ + "backfill", + "--satellites", + "goes16", + "--products", + "ABI-L2-ACMC", + "--stations", + "KNYC", + "--year-start", + "2024", + "--year-end", + "2024", + "--out", + str(tmp_path), + "--no-resume", + ] + ) + assert captured["resume"] is False + + def test_cli_executor_process(self, tmp_path) -> None: + from mostlyright.weather.satellite import __main__ as cli + + captured: dict = {} + + def _fake_bulk(**kw): + captured.update(kw) + return _backfill.BulkBackfillResult( + results=(), + total_scans_fetched=0, + total_rows_written=0, + slices_completed=0, + slices_skipped_resume=0, + duration_s=0.0, + ) + + with mock.patch.object(cli, "bulk_backfill", _fake_bulk): + cli.main( + [ + "backfill", + "--satellites", + "goes16", + "--products", + "ABI-L2-DSRF", + "--stations", + "KNYC", + "--year-start", + "2024", + "--year-end", + "2024", + "--out", + str(tmp_path), + "--executor", + "process", + ] + ) + assert captured["executor"] == "process" + + +class TestCLIPathHardening: + def test_malicious_satellite_rejected(self, tmp_path) -> None: + """P2-e: a path-traversal --satellites is rejected (no write escapes root). + + The real (un-mocked) bulk_backfill routes every (sat, product, station) + through the hardened satellite_cache_path; a malicious satellite value + is not in the {goes16, goes19} enum, so the run raises before any write. + """ + from mostlyright.weather.satellite import __main__ as cli + + with pytest.raises((ValueError, SystemExit)): + cli.main( + [ + "backfill", + "--satellites", + "goes16/../..", + "--products", + "ABI-L2-ACMC", + "--stations", + "KNYC", + "--year-start", + "2024", + "--year-end", + "2024", + "--out", + str(tmp_path), + ] + ) + + def test_malicious_product_rejected(self, tmp_path) -> None: + from mostlyright.weather.satellite import __main__ as cli + + with pytest.raises((ValueError, SystemExit)): + cli.main( + [ + "backfill", + "--satellites", + "goes16", + "--products", + "ABI-L2-ACMC/../..", + "--stations", + "KNYC", + "--year-start", + "2024", + "--year-end", + "2024", + "--out", + str(tmp_path), + ] + ) + + +# --------------------------------------------------------------------------- +# Task 4: docs (satellite.md + README) — cheap-CONUS, DSRF, --mirror, probe-derived +# --------------------------------------------------------------------------- +_REPO_ROOT = Path(__file__).resolve().parents[3] + + +class TestDocs: + def test_satellite_doc_exists_and_steers_to_cheap_conus(self) -> None: + doc = _REPO_ROOT / "docs" / "satellite.md" + assert doc.exists(), "docs/satellite.md must ship" + text = doc.read_text() + # Primary cheap CONUS product. + assert "ABI-L2-ACMC" in text + # DSRF gating warning. + assert "DSRF" in text + # 28TB / byte-range-useless / near-data reality. + assert "28" in text and "TB" in text + assert "byte-range" in text.lower() or "whole-file" in text.lower() + assert "near-data" in text.lower() or "in-region" in text.lower() + + def test_satellite_doc_documents_mirror_selector(self) -> None: + text = (_REPO_ROOT / "docs" / "satellite.md").read_text() + assert "--mirror" in text + assert "aws" in text and "gcp" in text + + def test_satellite_doc_documents_probe_derived_constants(self) -> None: + """D10 docs: max_workers + S3 rate cap are DERIVED from the probe; the + bare 'UNTUNED' caveat is replaced with the probe pointer + SOURCE-LIMITS.""" + text = (_REPO_ROOT / "docs" / "satellite.md").read_text() + assert "python -m mostlyright.weather.satellite probe" in text + assert "SOURCE-LIMITS" in text + assert "max_workers" in text + # If a bare "UNTUNED" claim appears it must co-exist with the probe + # pointer (no standalone UNTUNED caveat without the re-measure path). + if "UNTUNED" in text: + assert "probe" in text.lower() + + def test_readme_has_satellite_section(self) -> None: + readme = (_REPO_ROOT / "README.md").read_text() + assert "docs/satellite.md" in readme + assert "mostlyrightmd-weather[satellite]" in readme + + +# --------------------------------------------------------------------------- +# P2-2: a slice is marked ``completed`` ONLY when genuinely terminal — the +# (year, month) is FULLY ELAPSED in UTC (or it wrote rows). A current/future +# month writes nothing yet (the cache skips the incomplete current month; +# future listings return no rows) AND produces no errors, so the prior +# ``if resume and not res.errors`` wrongly marked it completed — a later resume +# then PERMANENTLY skipped it even once the data exists. +# --------------------------------------------------------------------------- +class TestResumeOnlyMarksTerminalSlices: + def test_future_month_with_no_rows_is_not_marked_completed(self, tmp_path) -> None: + """A future-year slice (no rows, no errors) must NOT be marked completed.""" + progress_path = tmp_path / _backfill._PROGRESS_FILENAME + + def _slice(**kw): + # Future months legitimately fetch + write nothing (no data yet). + return _stub_slice(**kw) + + with mock.patch.object(_backfill, "backfill_goes_satellite", _slice): + _backfill.bulk_backfill( + satellites=["goes16"], + products=["ABI-L2-ACMC"], + stations=["KNYC"], + year_start=2099, + year_end=2099, + out=tmp_path, + resume=True, + ) + progress = _backfill._load_progress(progress_path) + # No 2099 month may be marked completed — they are all in the future. + future_keys = [ + _backfill._progress_key("goes16", "ABI-L2-ACMC", "KNYC", 2099, m) for m in range(1, 13) + ] + assert all(progress.get(k) != _backfill._PROGRESS_COMPLETED for k in future_keys) + + def test_future_slice_is_reattempted_on_resume(self, tmp_path) -> None: + """Because it was not marked completed, a later resume re-runs the slice.""" + + def _slice(**kw): + return _stub_slice(**kw) + + def _run(): + ran: list[int] = [] + + def _spy(**kw): + ran.append(kw["month"]) + return _slice(**kw) + + with mock.patch.object(_backfill, "backfill_goes_satellite", _spy): + _backfill.bulk_backfill( + satellites=["goes16"], + products=["ABI-L2-ACMC"], + stations=["KNYC"], + year_start=2099, + year_end=2099, + out=tmp_path, + resume=True, + ) + return ran + + first = _run() + second = _run() + # The future months are re-attempted on the SECOND resume (not skipped). + assert sorted(first) == list(range(1, 13)) + assert sorted(second) == list(range(1, 13)) + + def test_fully_past_month_with_no_errors_is_marked_completed(self, tmp_path) -> None: + """A fully-elapsed month (no errors) IS terminal -> marked completed.""" + progress_path = tmp_path / _backfill._PROGRESS_FILENAME + + def _slice(**kw): + return _stub_slice(**kw) + + with mock.patch.object(_backfill, "backfill_goes_satellite", _slice): + _backfill.bulk_backfill( + satellites=["goes16"], + products=["ABI-L2-ACMC"], + stations=["KNYC"], + year_start=2020, + year_end=2020, + out=tmp_path, + resume=True, + ) + progress = _backfill._load_progress(progress_path) + past_keys = [ + _backfill._progress_key("goes16", "ABI-L2-ACMC", "KNYC", 2020, m) for m in range(1, 13) + ] + assert all(progress.get(k) == _backfill._PROGRESS_COMPLETED for k in past_keys) + + def test_past_completed_slice_is_skipped_on_resume(self, tmp_path) -> None: + """A fully-past slice marked completed on run 1 is skipped on run 2.""" + + def _slice(**kw): + return _stub_slice(**kw) + + # Run 1: marks all 2020 months completed. + with mock.patch.object(_backfill, "backfill_goes_satellite", _slice): + _backfill.bulk_backfill( + satellites=["goes16"], + products=["ABI-L2-ACMC"], + stations=["KNYC"], + year_start=2020, + year_end=2020, + out=tmp_path, + resume=True, + ) + + ran2: list[int] = [] + + def _spy(**kw): + ran2.append(kw["month"]) + return _slice(**kw) + + # Run 2: every 2020 month is already terminal -> skipped. + with mock.patch.object(_backfill, "backfill_goes_satellite", _spy): + res = _backfill.bulk_backfill( + satellites=["goes16"], + products=["ABI-L2-ACMC"], + stations=["KNYC"], + year_start=2020, + year_end=2020, + out=tmp_path, + resume=True, + ) + assert ran2 == [] + assert res.slices_skipped_resume == 12 + + def test_is_month_fully_elapsed_predicate(self) -> None: + """The elapsed predicate is in the past, not for the current/future month.""" + from datetime import UTC, datetime + + now = datetime.now(UTC) + assert _backfill._is_month_fully_elapsed(2020, 1) is True + # The current month is NOT fully elapsed. + assert _backfill._is_month_fully_elapsed(now.year, now.month) is False + # A far-future month is NOT fully elapsed. + assert _backfill._is_month_fully_elapsed(now.year + 5, 1) is False diff --git a/packages/weather/tests/test_satellite_cache.py b/packages/weather/tests/test_satellite_cache.py new file mode 100644 index 0000000..6037a64 --- /dev/null +++ b/packages/weather/tests/test_satellite_cache.py @@ -0,0 +1,359 @@ +"""Tests for the satellite merge policy + cache tier (Phase 25 Wave 2). + +Task 2 (dedup + validate): ``_dedup_satellite_rows`` first-seen-wins on the +6-tuple key (mirror-invariant), and ``_validate_satellite_record`` returns a +structured disposition (annotate-never-drop, D5) — never a quarantine. + +Task 3 (cache tier): ``satellite_cache_path`` hardens every user-controlled +path segment (station ICAO + satellite enum + product registry), the tier is +mirror-invariant (no mirror segment), and read/write/invalidate round-trip via +the shared ``_atomic_write`` chokepoint. +""" + +from __future__ import annotations + +import pytest +from mostlyright._internal.merge.satellite import ( + _dedup_satellite_rows, + _validate_satellite_record, +) +from mostlyright.weather import cache as cache_mod + +# ``_validate_satellite_record`` lazily imports the ``_goes_extract`` PRODUCTS +# registry (the [satellite] extra), so these tests require it. Skip cleanly in +# the base CI fast-suite (no extra); the satellite-coverage lane runs them. +try: + import boto3 # noqa: F401 + + _HAVE_SATELLITE_DEPS = True +except ImportError: # pragma: no cover - exercised only without the extra + _HAVE_SATELLITE_DEPS = False + +pytestmark = pytest.mark.skipif( + not _HAVE_SATELLITE_DEPS, + reason="satellite cache/validate tests require the [satellite] optional extra", +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def _row(**overrides) -> dict: + base = { + "station": "KNYC", + "satellite": "goes16", + "product": "ABI-L2-ACMC", + "variable": "BCM", + "pressure_level_hpa": None, + "scan_start_utc": "2024-06-15T12:01:17Z", + "scan_end_utc": "2024-06-15T12:03:17Z", + "delivery": "live", + "source_object_key": "ABI-L2-ACMC/2024/167/12/OR_...nc", + "ingested_at": None, + "pixel_value": 1.0, + "pixel_dqf": None, + "pixel_row": 100, + "pixel_col": 200, + "units": "1", + "station_lat": 40.779, + "station_lon": -73.969, + "sat_lon_used": -75.0, + "qc_status": "clean", + "as_of_time": None, + } + base.update(overrides) + return base + + +# --------------------------------------------------------------------------- +# _dedup_satellite_rows — first-seen-wins on the full 6-tuple +# --------------------------------------------------------------------------- +def test_dedup_first_seen_wins_on_identical_key() -> None: + first = _row(pixel_value=1.0, source_object_key="a.nc") + second = _row(pixel_value=0.0, source_object_key="b.nc") # same key + out = _dedup_satellite_rows([first, second]) + assert len(out) == 1 + assert out[0]["pixel_value"] == 1.0 # first-seen kept + assert out[0]["source_object_key"] == "a.nc" + + +def test_dedup_distinct_keys_all_kept() -> None: + rows = [ + _row(variable="BCM"), + _row(variable="ACM"), + _row(scan_start_utc="2024-06-15T12:06:17Z"), + _row(satellite="goes19"), + _row(pressure_level_hpa=500.0), + ] + out = _dedup_satellite_rows(rows) + assert len(out) == 5 + + +def test_dedup_mirror_invariant_collapse() -> None: + """Two rows identical except their source mirror collapse to one. + + The dedup key has no mirror component — the same NOAA GOES scan fetched + from AWS or GCS is the SAME row (mirror is transport-only, D9). Here the + only difference is the source_object_key bucket prefix; the 6-tuple key is + identical so the rows must collapse. + """ + aws = _row(source_object_key="noaa-goes16/ABI-L2-ACMC/.../a.nc") + gcp = _row(source_object_key="gcp-public-data-goes-16/ABI-L2-ACMC/.../a.nc") + out = _dedup_satellite_rows([aws, gcp]) + assert len(out) == 1 + + +def test_dedup_empty_input() -> None: + assert _dedup_satellite_rows([]) == [] + + +# --------------------------------------------------------------------------- +# _validate_satellite_record — structured disposition, never quarantine +# --------------------------------------------------------------------------- +def _is_clean(disposition) -> bool: + """A disposition is clean when it carries no findings.""" + return list(disposition) == [] + + +def test_validate_clean_record() -> None: + assert _is_clean(_validate_satellite_record(_row())) + + +def test_validate_units_mismatch_is_nonclean() -> None: + disp = _validate_satellite_record(_row(units="WRONG")) + assert not _is_clean(disp) + + +def test_validate_3d_missing_pressure_is_nonclean() -> None: + # LVMPC/LVTPC is_3d_profile products require a pressure level. + disp = _validate_satellite_record( + _row( + product="ABI-L2-LVMPC", + variable="LVM", + units="percent", + pixel_value=50.0, + pixel_dqf=0, + pressure_level_hpa=None, + ) + ) + assert not _is_clean(disp) + + +def test_validate_physics_bounds_violation_is_nonclean() -> None: + # BCM bounds are (0.0, 1.0); 9.0 is out of range. + disp = _validate_satellite_record(_row(pixel_value=9.0)) + assert not _is_clean(disp) + + +def test_validate_dqf_nullability_violation_is_nonclean() -> None: + # BCM has dqf_good=None → pixel_dqf must be null; a non-null dqf is flagged. + disp = _validate_satellite_record(_row(pixel_dqf=0)) + assert not _is_clean(disp) + + +def test_validate_no_quarantine_path() -> None: + """The validator returns a disposition only — it never writes a parquet. + + Asserts the module has no parquet-write surface and no + ``quarantine_*``-style path construction (the 2i sibling-quarantine + pattern). The word "quarantine" may appear in prose explaining its ABSENCE, + so the guard checks executable tokens, not prose. + """ + import ast + import inspect + + from mostlyright._internal.merge import satellite as mod + + tree = ast.parse(inspect.getsource(mod)) + for node in ast.walk(tree): + body = getattr(node, "body", None) + if ( + isinstance(body, list) + and body + and isinstance(body[0], ast.Expr) + and isinstance(body[0].value, ast.Constant) + and isinstance(body[0].value.value, str) + ): + body.pop(0) + code_only = ast.unparse(tree) + assert "quarantine" not in code_only.lower() + assert "write_table" not in code_only + assert "pq.write" not in code_only + + +def test_validate_fillvalue_none_is_clean() -> None: + """pixel_value=None on a NetCDF _FillValue is a clean data condition.""" + assert _is_clean(_validate_satellite_record(_row(pixel_value=None))) + + +def test_validate_scan_end_equal_start_accepted() -> None: + """M4: scan_end == scan_start is accepted (continuous full-disk filename).""" + disp = _validate_satellite_record( + _row( + scan_start_utc="2017-08-01T16:05:22Z", + scan_end_utc="2017-08-01T16:05:22Z", + ) + ) + assert _is_clean(disp) + + +def test_validate_scan_end_before_start_is_nonclean() -> None: + """M4: scan_end < scan_start is the only hard temporal reject.""" + disp = _validate_satellite_record( + _row( + scan_start_utc="2024-06-15T12:05:00Z", + scan_end_utc="2024-06-15T12:04:00Z", + ) + ) + assert not _is_clean(disp) + + +def test_validate_empty_source_object_key_is_nonclean() -> None: + disp = _validate_satellite_record(_row(source_object_key="")) + assert not _is_clean(disp) + + +# --------------------------------------------------------------------------- +# Task 3 — satellite cache tier +# --------------------------------------------------------------------------- +@pytest.fixture +def cache_root(tmp_path, monkeypatch) -> object: + """Point the cache root at an isolated tmp dir for path + roundtrip tests.""" + monkeypatch.setenv("MOSTLYRIGHT_CACHE_DIR", str(tmp_path)) + return tmp_path + + +def test_satellite_cache_path_layout(cache_root) -> None: + path = cache_mod.satellite_cache_path("goes16", "ABI-L2-ACMC", "KNYC", 2024, 3) + parts = path.parts + assert parts[-6:] == ("satellite", "goes16", "ABI-L2-ACMC", "KNYC", "2024", "03.parquet") + assert "v1" in parts + # under the cache root + assert str(path).startswith(str(cache_root)) + + +def test_satellite_cache_path_no_mirror_segment(cache_root) -> None: + """D9: the cache path has NO mirror segment — mirror-invariant.""" + path = cache_mod.satellite_cache_path("goes16", "ABI-L2-ACMC", "KNYC", 2024, 3) + assert "aws" not in path.parts + assert "gcp" not in path.parts + + +def test_satellite_cache_path_rejects_bad_station(cache_root) -> None: + # path-traversal station rejected by validate_icao_for_path / assert_path_under + with pytest.raises(ValueError): + cache_mod.satellite_cache_path("goes16", "ABI-L2-ACMC", "../../etc", 2024, 3) + with pytest.raises(ValueError): + cache_mod.satellite_cache_path("goes16", "ABI-L2-ACMC", "K/NY", 2024, 3) + + +@pytest.mark.parametrize("bad_sat", ["../", "goes16/../..", "goes17", "goes16/x"]) +def test_satellite_cache_path_rejects_bad_satellite(cache_root, bad_sat) -> None: + with pytest.raises(ValueError): + cache_mod.satellite_cache_path(bad_sat, "ABI-L2-ACMC", "KNYC", 2024, 3) + + +@pytest.mark.parametrize( + "bad_product", ["../etc", "ABI-L2-ACMC/../..", "ABI-L2-NOPE", "ABI-L2-ACMC/x"] +) +def test_satellite_cache_path_rejects_bad_product(cache_root, bad_product) -> None: + with pytest.raises(ValueError): + cache_mod.satellite_cache_path("goes16", bad_product, "KNYC", 2024, 3) + + +def test_write_satellite_cache_empty_is_noop(cache_root) -> None: + cache_mod.write_satellite_cache("goes16", "ABI-L2-ACMC", "KNYC", 2020, 3, []) + path = cache_mod.satellite_cache_path("goes16", "ABI-L2-ACMC", "KNYC", 2020, 3) + assert not path.exists() + + +def test_write_then_read_satellite_cache_roundtrip(cache_root) -> None: + rows = [_row(scan_start_utc="2020-03-15T12:00:00Z")] + cache_mod.write_satellite_cache("goes16", "ABI-L2-ACMC", "KNYC", 2020, 3, rows) + back = cache_mod.read_satellite_cache("goes16", "ABI-L2-ACMC", "KNYC", 2020, 3) + assert back is not None + assert len(back) == 1 + assert back[0]["station"] == "KNYC" + assert back[0]["pixel_value"] == 1.0 + + +def test_write_merge_dedups_existing_partition(cache_root) -> None: + row = _row(scan_start_utc="2020-03-15T12:00:00Z", source_object_key="a.nc") + cache_mod.write_satellite_cache("goes16", "ABI-L2-ACMC", "KNYC", 2020, 3, [row]) + # write an overlapping (same-key) row + a new one + dup = _row(scan_start_utc="2020-03-15T12:00:00Z", source_object_key="b.nc") + new = _row(scan_start_utc="2020-03-15T12:05:00Z", source_object_key="c.nc") + cache_mod.write_satellite_cache("goes16", "ABI-L2-ACMC", "KNYC", 2020, 3, [dup, new]) + back = cache_mod.read_satellite_cache("goes16", "ABI-L2-ACMC", "KNYC", 2020, 3) + assert back is not None + assert len(back) == 2 # dup collapsed; new added + keys = {(r["scan_start_utc"], r["source_object_key"]) for r in back} + # first-seen-wins: the original a.nc survives, b.nc dropped + assert ("2020-03-15T12:00:00Z", "a.nc") in keys + assert ("2020-03-15T12:00:00Z", "b.nc") not in keys + # no .tmp staging left behind + path = cache_mod.satellite_cache_path("goes16", "ABI-L2-ACMC", "KNYC", 2020, 3) + assert not path.with_suffix(".tmp").exists() + + +def test_invalidate_satellite_removes_partition(cache_root) -> None: + rows = [_row(scan_start_utc="2020-03-15T12:00:00Z")] + cache_mod.write_satellite_cache("goes16", "ABI-L2-ACMC", "KNYC", 2020, 3, rows) + path = cache_mod.satellite_cache_path("goes16", "ABI-L2-ACMC", "KNYC", 2020, 3) + assert path.exists() + assert cache_mod.invalidate_satellite("goes16", "ABI-L2-ACMC", "KNYC", 2020, 3) is True + assert not path.exists() + + +def test_write_current_utc_month_skip(cache_root) -> None: + """A6: current-UTC-month write/read is skipped like the forecast tier.""" + from datetime import UTC, datetime + + now = datetime.now(UTC) + rows = [_row(scan_start_utc=f"{now.year:04d}-{now.month:02d}-01T00:00:00Z")] + cache_mod.write_satellite_cache("goes16", "ABI-L2-ACMC", "KNYC", now.year, now.month, rows) + path = cache_mod.satellite_cache_path("goes16", "ABI-L2-ACMC", "KNYC", now.year, now.month) + assert not path.exists() + assert ( + cache_mod.read_satellite_cache("goes16", "ABI-L2-ACMC", "KNYC", now.year, now.month) is None + ) + + +def test_write_satellite_cache_serializes_read_under_lock(cache_root, monkeypatch) -> None: + """P2 fix: the existing-partition read runs INSIDE the FileLock. + + If a concurrent holder owns the partition lock, ``write_satellite_cache`` + must block on lock acquisition BEFORE it can read the existing partition — + proving the whole read→merge→write sequence is serialized (no lost update). + Previously only the write half acquired the lock, so the read at + ``pq.read_table`` raced and the second writer could clobber the first. + """ + from filelock import FileLock, Timeout + + # Seed an existing partition so the merge read-path is exercised. + seed = _row(scan_start_utc="2020-03-15T12:00:00Z", source_object_key="seed.nc") + cache_mod.write_satellite_cache("goes16", "ABI-L2-ACMC", "KNYC", 2020, 3, [seed]) + path = cache_mod.satellite_cache_path("goes16", "ABI-L2-ACMC", "KNYC", 2020, 3) + + # Force a short, deterministic lock timeout so the blocked write fails fast. + monkeypatch.setattr(cache_mod, "LOCK_TIMEOUT_SECONDS", 0.2) + + # Tripwire: if the read ever runs, it must be because we already hold the + # lock. We assert the read NEVER fires while an external holder owns the + # lock (the acquire blocks first and times out). + read_calls: list[object] = [] + orig_read = cache_mod.pq.read_table + + def _tripwire_read(*args, **kwargs): + read_calls.append(args) + return orig_read(*args, **kwargs) + + monkeypatch.setattr(cache_mod.pq, "read_table", _tripwire_read) + + held = FileLock(str(path) + ".lock", timeout=5) + with held: + new = _row(scan_start_utc="2020-03-15T12:09:00Z", source_object_key="new.nc") + with pytest.raises(Timeout): + cache_mod.write_satellite_cache("goes16", "ABI-L2-ACMC", "KNYC", 2020, 3, [new]) + # The read must NOT have executed: acquisition blocked before the read. + assert read_calls == [] diff --git a/packages/weather/tests/test_satellite_extract.py b/packages/weather/tests/test_satellite_extract.py new file mode 100644 index 0000000..2848321 --- /dev/null +++ b/packages/weather/tests/test_satellite_extract.py @@ -0,0 +1,750 @@ +"""Tests for the GOES ABI L2 single-pixel extraction engine (Phase 25 Wave 1). + +All fixtures are in-memory ``xr.Dataset``s (see ``conftest.py``) — ZERO +network, ZERO checked-in binary NetCDF. The S3 transport (``_goes_s3.py``) +lives in 25-03 and is out of scope here; ``_extract_from_dataset`` takes an +already-open Dataset. +""" + +from __future__ import annotations + +import math + +import pytest + +# Plain optional imports (not pytest.importorskip at module top level) — that +# avoids the coverage+importorskip double-import skip on Python 3.14. When the +# [satellite] extra is absent, ``pytestmark`` skips the whole module cleanly. +try: + import numpy as np + import xarray as xr + + _HAVE_SATELLITE_DEPS = True +except ImportError: # pragma: no cover + np = None # type: ignore[assignment] + xr = None # type: ignore[assignment] + _HAVE_SATELLITE_DEPS = False + +pytestmark = pytest.mark.skipif( + not _HAVE_SATELLITE_DEPS, + reason="satellite extraction tests require the [satellite] optional extra (numpy/xarray)", +) + +# Reference values mirroring conftest's projection fixtures (KNYC / GOES-East). +# Re-declared test-locally so the projection-exactness assertions do not depend +# on importing the conftest module by name (pytest does not put the test dir on +# sys.path, so ``import conftest`` is unreliable). +STATION_LAT = 40.7789 +STATION_LON = -73.9692 +NADIR_LON = -75.0 +_R_EQ = 6378137.0 +_R_POL = 6356752.31414 +_PPH = 35786023.0 + + +def _ref_abi_scan_angles(lat_deg: float, lon_deg: float, nadir_lon: float): + """Independent forward ABI projection (PUG Vol5 App A) for exactness checks.""" + h = _PPH + _R_EQ + e = math.sqrt(1.0 - (_R_POL / _R_EQ) ** 2) + lam0 = math.radians(nadir_lon) + phi = math.radians(lat_deg) + lam = math.radians(lon_deg) + phi_c = math.atan((_R_POL / _R_EQ) ** 2 * math.tan(phi)) + r_c = _R_POL / math.sqrt(1.0 - (e * math.cos(phi_c)) ** 2) + sx = h - r_c * math.cos(phi_c) * math.cos(lam - lam0) + sy = -r_c * math.cos(phi_c) * math.sin(lam - lam0) + sz = r_c * math.sin(phi_c) + y_scan = math.atan(sz / sx) + x_scan = math.asin(-sy / math.sqrt(sx**2 + sy**2 + sz**2)) + return x_scan, y_scan + + +# =========================================================================== +# Task 1 — fixture smoke tests (no port import required) +# =========================================================================== +class TestQuirkFixtures: + def test_fixture_lst_cold_builds(self, ds_lst_cold) -> None: + ds = ds_lst_cold["ds"] + assert "goes_imager_projection" in ds.variables + assert "LST" in ds.variables + assert tuple(ds["LST"].dims) == ("y", "x") + # raw is below the old 180 K floor once decoded + assert ds_lst_cold["expected_k"] < 180.0 + + def test_fixture_cloud_prob_builds(self, ds_cloud_prob_physical_range) -> None: + ds = ds_cloud_prob_physical_range["ds"] + cp = ds["Cloud_Probabilities"] + assert "units" not in cp.attrs # missing units like the real file + assert list(cp.attrs["valid_range"]) == [0, 1] # PHYSICAL units + + def test_fixture_dsr_unsigned_builds(self, ds_dsr_unsigned) -> None: + ds = ds_dsr_unsigned["ds"] + dsr = ds["DSR"] + assert dsr.dtype == np.int16 + assert str(dsr.attrs["_Unsigned"]).lower() == "true" + row, col = ds_dsr_unsigned["row"], ds_dsr_unsigned["col"] + assert int(dsr.values[row, col]) < 0 # negative as signed int16 + + def test_fixture_acm_no_units_builds(self, ds_acm_no_units) -> None: + ds = ds_acm_no_units["ds"] + assert "units" not in ds["ACM"].attrs + + def test_fixture_dsrf_latlon_builds(self, ds_dsrf_latlon) -> None: + ds = ds_dsrf_latlon["ds"] + assert "goes_lat_lon_projection" in ds.variables + assert "goes_imager_projection" not in ds.variables + assert tuple(ds["DSR"].dims) == ("lat", "lon") + + def test_fixture_dsrf_abi_builds(self, ds_dsrf_abi) -> None: + ds = ds_dsrf_abi["ds"] + assert "goes_imager_projection" in ds.variables + assert tuple(ds["DSR"].dims) == ("y", "x") + + def test_fixture_units_mismatch_multivar_builds(self, ds_units_mismatch_multivar) -> None: + ds = ds_units_mismatch_multivar["ds"] + assert ds["LI"].attrs["units"] == "K" + assert ds["CAPE"].attrs["units"] == "WRONG/units" + + def test_fixture_roundtrip_netcdf(self, ds_lst_cold, tmp_path) -> None: + """A fixture may roundtrip to NetCDF and reopen (h5netcdf engine). + + The h5netcdf WRITE path needs ``h5py``; the whole-file READ transport + is 25-03's concern, so this optional roundtrip is skipped when h5py is + absent. The in-memory fixtures (which all other tests use) need neither. + """ + pytest.importorskip("h5netcdf") + pytest.importorskip("h5py") + path = tmp_path / "f.nc" + ds_lst_cold["ds"].to_netcdf(path, engine="h5netcdf") + reopened = xr.open_dataset( + path, engine="h5netcdf", mask_and_scale=False, decode_times=False + ) + assert "LST" in reopened.variables + reopened.close() + + +# =========================================================================== +# Task 2 — PRODUCTS registry + projection math + dual-projection branch +# =========================================================================== +class TestProductsRegistry: + def test_registry_has_all_expected_products(self) -> None: + from mostlyright.weather._fetchers._goes_extract import PRODUCTS + + products = {p for (p, _v) in PRODUCTS} + # The full registry: v1 CONUS + DSRF + 3D profiles. + for code in ( + "ABI-L2-ACMC", + "ABI-L2-LSTC", + "ABI-L2-DSIC", + "ABI-L2-TPWC", + "ABI-L2-DSRF", + "ABI-L2-LVMPC", + "ABI-L2-LVTPC", + ): + assert code in products, f"{code} missing from PRODUCTS" + + def test_registry_entries_carry_grid_shape_expected(self) -> None: + from mostlyright.weather._fetchers._goes_extract import PRODUCTS + + for key, pv in PRODUCTS.items(): + assert isinstance(pv.grid_shape_expected, tuple), key + assert len(pv.grid_shape_expected) == 2, key + + def test_dsrf_grid_shape_is_full_disk(self) -> None: + """DSRF carries the goes19 full-disk shape (per-satellite split, D5).""" + from mostlyright.weather._fetchers._goes_extract import PRODUCTS + + assert PRODUCTS[("ABI-L2-DSRF", "DSR")].grid_shape_expected == (5424, 5424) + + def test_cloud_probabilities_valid_range_filter_false(self) -> None: + from mostlyright.weather._fetchers._goes_extract import PRODUCTS + + assert PRODUCTS[("ABI-L2-ACMC", "Cloud_Probabilities")].valid_range_filter is False + + def test_lst_bounds_widened_to_100_350(self) -> None: + from mostlyright.weather._fetchers._goes_extract import PRODUCTS + + assert PRODUCTS[("ABI-L2-LSTC", "LST")].bounds == (100.0, 350.0) + + def test_acm_units_expected_empty_string(self) -> None: + from mostlyright.weather._fetchers._goes_extract import PRODUCTS + + assert PRODUCTS[("ABI-L2-ACMC", "ACM")].units_expected == "" + + def test_get_product_variable_resolves_and_misses(self) -> None: + from mostlyright.weather._fetchers._goes_extract import get_product_variable + + assert get_product_variable("ABI-L2-ACMC", "ACM") is not None + assert get_product_variable("ABI-L2-ACMC", "NOPE") is None + + def test_variables_for_product_and_tiers(self) -> None: + from mostlyright.weather._fetchers._goes_extract import ( + products_in_tier, + variables_for_product, + ) + + assert "ACM" in variables_for_product("ABI-L2-ACMC") + assert "ABI-L2-ACMC" in products_in_tier("v1") + + def test_known_products_set_derived(self) -> None: + from mostlyright.weather._fetchers._goes_extract import ( + _KNOWN_PRODUCTS, + PRODUCTS, + ) + + assert {p for (p, _v) in PRODUCTS} == _KNOWN_PRODUCTS + + +class TestAbiProjection: + def test_latlon_to_abi_xy_matches_independent_forward(self, ds_dsrf_abi) -> None: + """Port's forward projection matches an independent forward (exact).""" + from mostlyright.weather._fetchers._goes_extract import ( + _read_projection_params, + latlon_to_abi_xy, + ) + + proj = _read_projection_params(ds_dsrf_abi["ds"]) + x_scan, y_scan = latlon_to_abi_xy(STATION_LAT, STATION_LON, proj) + exp_x, exp_y = _ref_abi_scan_angles(STATION_LAT, STATION_LON, NADIR_LON) + assert x_scan == pytest.approx(exp_x, abs=1e-12) + assert y_scan == pytest.approx(exp_y, abs=1e-12) + + def test_compute_pixel_indices_centers_station(self, ds_dsrf_abi) -> None: + from mostlyright.weather._fetchers._goes_extract import ( + _read_grid_params, + _read_projection_params, + compute_pixel_indices, + latlon_to_abi_xy, + ) + + ds = ds_dsrf_abi["ds"] + proj = _read_projection_params(ds) + grid = _read_grid_params(ds) + x_scan, y_scan = latlon_to_abi_xy(STATION_LAT, STATION_LON, proj) + row, col = compute_pixel_indices(x_scan, y_scan, grid) + assert (row, col) == (ds_dsrf_abi["row"], ds_dsrf_abi["col"]) + + def test_compute_pixel_indices_out_of_grid_raises(self, ds_dsrf_abi) -> None: + from mostlyright.core.exceptions import StationOutOfGridError + from mostlyright.weather._fetchers._goes_extract import ( + _read_grid_params, + compute_pixel_indices, + ) + + grid = _read_grid_params(ds_dsrf_abi["ds"]) + # A scan angle far outside the tiny grid. + with pytest.raises(StationOutOfGridError): + compute_pixel_indices(99.0, 99.0, grid) + + +class TestLatLonProjection: + def test_latlon_to_ll_pixel_nearest_index(self, ds_dsrf_latlon) -> None: + from mostlyright.weather._fetchers._goes_extract import ( + _read_lat_lon_grid, + latlon_to_ll_pixel, + ) + + grid = _read_lat_lon_grid(ds_dsrf_latlon["ds"]) + row, col = latlon_to_ll_pixel(STATION_LAT, STATION_LON, grid) + assert (row, col) == (ds_dsrf_latlon["row"], ds_dsrf_latlon["col"]) + + def test_latlon_to_ll_pixel_out_of_grid_raises(self, ds_dsrf_latlon) -> None: + from mostlyright.core.exceptions import StationOutOfGridError + from mostlyright.weather._fetchers._goes_extract import ( + _read_lat_lon_grid, + latlon_to_ll_pixel, + ) + + grid = _read_lat_lon_grid(ds_dsrf_latlon["ds"]) + with pytest.raises(StationOutOfGridError): + latlon_to_ll_pixel(0.0, 0.0, grid) + + +class TestMalformedProjectionAttrs: + """P2 fix: per-attr ``GoesDataCorruptError`` branches in the readers. + + The 'no projection variable' case is covered elsewhere. These cover the + other half: the projection/grid/latlon variable is PRESENT but missing a + single required attr — each branch must raise loudly, not decode garbage. + """ + + def test_projection_missing_required_attr_raises(self, ds_dsrf_abi) -> None: + from mostlyright.core.exceptions import GoesDataCorruptError + from mostlyright.weather._fetchers._goes_extract import _read_projection_params + + ds = ds_dsrf_abi["ds"].copy() + proj = ds["goes_imager_projection"] + # Copy attrs so we don't mutate the shared fixture dict in place. + attrs = dict(proj.attrs) + del attrs["semi_major_axis"] + proj.attrs = attrs + with pytest.raises(GoesDataCorruptError, match="missing attr: semi_major_axis"): + _read_projection_params(ds) + + def test_grid_missing_scale_factor_raises(self, ds_dsrf_abi) -> None: + from mostlyright.core.exceptions import GoesDataCorruptError + from mostlyright.weather._fetchers._goes_extract import _read_grid_params + + ds = ds_dsrf_abi["ds"].copy() + x_attrs = dict(ds.coords["x"].attrs) + del x_attrs["scale_factor"] + ds.coords["x"].attrs = x_attrs + with pytest.raises(GoesDataCorruptError, match="missing required attr: scale_factor"): + _read_grid_params(ds) + + def test_latlon_grid_missing_add_offset_raises(self, ds_dsrf_latlon) -> None: + from mostlyright.core.exceptions import GoesDataCorruptError + from mostlyright.weather._fetchers._goes_extract import _read_lat_lon_grid + + ds = ds_dsrf_latlon["ds"].copy() + lat_attrs = dict(ds.coords["lat"].attrs) + del lat_attrs["add_offset"] + ds.coords["lat"].attrs = lat_attrs + with pytest.raises( + GoesDataCorruptError, match="lat coord missing required attr: add_offset" + ): + _read_lat_lon_grid(ds) + + +class TestParseScanTimes: + def test_parse_scan_times_stdlib_only(self) -> None: + from datetime import UTC, datetime + + from mostlyright.weather._fetchers._goes_extract import parse_scan_times + + key = ( + "ABI-L2-ACMC/2024/167/12/" + "OR_ABI-L2-ACMC-M6_G16_s20241671201178_e20241671203551_c20241671204010.nc" + ) + start, end = parse_scan_times(key) + assert start == datetime(2024, 6, 15, 12, 1, 17, tzinfo=UTC) + # 2024 day-of-year 167 == June 15 + assert end >= start # M4 quirk: end >= start permitted + + def test_parse_scan_times_missing_raises(self) -> None: + from mostlyright.weather._fetchers._goes_extract import parse_scan_times + + with pytest.raises(ValueError): + parse_scan_times("garbage_key_no_timestamps.nc") + + +# A real GOES key whose filename parses cleanly (KNYC station). +_KEY = ( + "ABI-L2-ACMC/2024/167/12/" + "OR_ABI-L2-ACMC-M6_G16_s20241671201178_e20241671203551_c20241671204010.nc" +) + + +@pytest.fixture +def knyc_station(): + """KNYC StationInfo (the station the projection fixtures center on).""" + from mostlyright._internal._stations import StationInfo + + return StationInfo( + code="NYC", + ghcnh_id="USW00094728", + icao="KNYC", + name="New York Central Park", + tz="America/New_York", + latitude=STATION_LAT, + longitude=STATION_LON, + ) + + +def _entries(*keys): + from mostlyright.weather._fetchers._goes_extract import PRODUCTS + + return [PRODUCTS[k] for k in keys] + + +# =========================================================================== +# Task 3 — value decode + record build (quirks, ICAO, units-suspect-continue) +# =========================================================================== +class TestScaleOffsetQuirks: + def test_dsr_unsigned_decodes_positive(self, ds_dsr_unsigned) -> None: + """Quirk 3 — _Unsigned int16->uint16: DSR comes out LARGE POSITIVE.""" + from mostlyright.weather._fetchers._goes_extract import _apply_scale_offset + + ds = ds_dsr_unsigned["ds"] + var = ds["DSR"] + row, col = ds_dsr_unsigned["row"], ds_dsr_unsigned["col"] + raw = np.array([[var.isel(y=row, x=col).values]]) + scaled = _apply_scale_offset(raw, dict(var.attrs)) + # uint16 33798 * 0.02 == ~675.96 (positive), NOT ~-635 (signed). + assert scaled[0, 0] > 600.0 + + def test_signed_without_unsigned_attr_stays_negative(self) -> None: + """Sanity: WITHOUT _Unsigned the same bit pattern decodes negative.""" + from mostlyright.weather._fetchers._goes_extract import _apply_scale_offset + + raw = np.array([[np.int16(-31738)]]) + scaled = _apply_scale_offset(raw, {"scale_factor": 0.02, "add_offset": 0.0}) + assert scaled[0, 0] < 0.0 + + def test_fillvalue_becomes_nan(self) -> None: + from mostlyright.weather._fetchers._goes_extract import _apply_scale_offset + + raw = np.array([[np.int16(-1)]]) + scaled = _apply_scale_offset(raw, {"_FillValue": np.int16(-1), "scale_factor": 0.5}) + assert np.isnan(scaled[0, 0]) + + +class TestValidRangeQuirks: + def test_cloud_probabilities_filter_disabled_passes_through( + self, ds_cloud_prob_physical_range + ) -> None: + """Quirk 2 — valid_range_filter=False passes physical values unchanged.""" + from mostlyright.weather._fetchers._goes_extract import ( + _apply_scale_offset, + _apply_valid_range, + ) + + ds = ds_cloud_prob_physical_range["ds"] + var = ds["Cloud_Probabilities"] + row, col = ( + ds_cloud_prob_physical_range["row"], + ds_cloud_prob_physical_range["col"], + ) + raw = np.array([[var.isel(y=row, x=col).values]]) + scaled = _apply_scale_offset(raw, dict(var.attrs)) + # With the filter DISABLED the ~0.8 value survives. + out = _apply_valid_range(scaled, dict(var.attrs), enabled=False) + assert out[0, 0] == pytest.approx(0.8, abs=1e-3) + # If the filter were (wrongly) enabled, the physical-units valid_range + # [0,1] scaled by raw*scale would collapse and reject 0.8. + masked = _apply_valid_range(scaled, dict(var.attrs), enabled=True) + assert np.isnan(masked[0, 0]) + + def test_lst_cold_floor_survives_valid_range(self, ds_lst_cold) -> None: + """Quirk 1 — a ~170 K nighttime LST is retained, not clipped.""" + from mostlyright.weather._fetchers._goes_extract import ( + _apply_scale_offset, + _apply_valid_range, + ) + + ds = ds_lst_cold["ds"] + var = ds["LST"] + row, col = ds_lst_cold["row"], ds_lst_cold["col"] + raw = np.array([[var.isel(y=row, x=col).values]]) + scaled = _apply_scale_offset(raw, dict(var.attrs)) + out = _apply_valid_range(scaled, dict(var.attrs), enabled=True) + assert out[0, 0] == pytest.approx(170.0, abs=0.5) + + +class TestReadPixelDqf: + def test_dqf_none_when_registry_declares_none(self, ds_acm_no_units) -> None: + """_read_pixel_dqf returns None for a var with no DQF (BCM/ACM).""" + from mostlyright.weather._fetchers._goes_extract import ( + PRODUCTS, + _read_pixel_dqf, + ) + + ds = ds_acm_no_units["ds"] + pv = PRODUCTS[("ABI-L2-ACMC", "ACM")] # dqf_good=None + out = _read_pixel_dqf( + ds, + pv, + ds_acm_no_units["row"], + ds_acm_no_units["col"], + isel_kwargs={"y": ds_acm_no_units["row"], "x": ds_acm_no_units["col"]}, + ) + assert out is None + + +class TestExtractFromDataset: + def test_acm_no_units_yields_empty_string(self, ds_acm_no_units, knyc_station) -> None: + """Quirk 4 — extracting ACM with no units attr yields units == ''.""" + from mostlyright.weather._fetchers._goes_extract import _extract_from_dataset + + records = _extract_from_dataset( + ds_acm_no_units["ds"], + s3_key=_KEY, + product="ABI-L2-ACMC", + station=knyc_station, + satellite="goes16", + ingested_at=None, + var_entries=_entries(("ABI-L2-ACMC", "ACM")), + ) + assert len(records) == 1 + assert records[0]["units"] == "" + + def test_dsrf_routes_through_latlon_branch(self, ds_dsrf_latlon, knyc_station) -> None: + """Quirk 5a — goes16 DSRF routes through the lat/lon branch.""" + from mostlyright.weather._fetchers._goes_extract import _extract_from_dataset + + records = _extract_from_dataset( + ds_dsrf_latlon["ds"], + s3_key=_KEY, + product="ABI-L2-DSRF", + station=knyc_station, + satellite="goes16", + ingested_at=None, + var_entries=_entries(("ABI-L2-DSRF", "DSR")), + ) + assert len(records) == 1 + assert records[0]["pixel_row"] == ds_dsrf_latlon["row"] + assert records[0]["pixel_col"] == ds_dsrf_latlon["col"] + assert records[0]["pixel_value"] == pytest.approx(675.0, abs=1.0) + + def test_dsrf_routes_through_abi_branch(self, ds_dsrf_abi, knyc_station) -> None: + """Quirk 5b — goes19 DSRF routes through the ABI branch.""" + from mostlyright.weather._fetchers._goes_extract import _extract_from_dataset + + records = _extract_from_dataset( + ds_dsrf_abi["ds"], + s3_key=_KEY, + product="ABI-L2-DSRF", + station=knyc_station, + satellite="goes19", + ingested_at=None, + var_entries=_entries(("ABI-L2-DSRF", "DSR")), + ) + assert len(records) == 1 + assert records[0]["pixel_row"] == ds_dsrf_abi["row"] + assert records[0]["pixel_value"] == pytest.approx(675.0, abs=1.0) + + def test_fillvalue_pixel_value_none(self, knyc_station, ds_dsrf_abi) -> None: + """_FillValue at the station cell => pixel_value is None (data cond.).""" + from mostlyright.weather._fetchers._goes_extract import _extract_from_dataset + + ds = ds_dsrf_abi["ds"] + row, col = ds_dsrf_abi["row"], ds_dsrf_abi["col"] + ds["DSR"].values[row, col] = -1 # == _FillValue + records = _extract_from_dataset( + ds, + s3_key=_KEY, + product="ABI-L2-DSRF", + station=knyc_station, + satellite="goes19", + ingested_at=None, + var_entries=_entries(("ABI-L2-DSRF", "DSR")), + ) + assert records[0]["pixel_value"] is None + + def test_valid_range_pixel_value_none(self, knyc_station, ds_lst_cold) -> None: + """Out-of-valid_range (but not _FillValue) pixel => pixel_value is None. + + Guards the ``_apply_valid_range`` wiring in ``_extract_from_dataset``. + LST ``valid_range`` is raw ``[0, 30000]`` (=> ``[100, 400] K`` after + scale/offset). A raw ``30001`` decodes to ``400.01 K`` — just past the + upper bound and != ``_FillValue`` (-1) — so it must be masked to None. + Without the wiring it would leak through as a "clean" ~400 K value + (registry ``bounds`` is only enforced later, at validate/audit). + """ + from mostlyright.weather._fetchers._goes_extract import _extract_from_dataset + + ds = ds_lst_cold["ds"] + row, col = ds_lst_cold["row"], ds_lst_cold["col"] + ds["LST"].values[row, col] = 30001 # outside valid_range[0,30000], != _FillValue + records = _extract_from_dataset( + ds, + s3_key=_KEY, + product="ABI-L2-LSTC", + station=knyc_station, + satellite="goes16", + ingested_at=None, + var_entries=_entries(("ABI-L2-LSTC", "LST")), + ) + assert records[0]["pixel_value"] is None + + def test_build_record_writes_icao_and_delivery_fields(self, ds_dsrf_abi, knyc_station) -> None: + """ICAO build — station==KNYC + delivery/qc_status/as_of_time present.""" + from mostlyright.weather._fetchers._goes_extract import _extract_from_dataset + + records = _extract_from_dataset( + ds_dsrf_abi["ds"], + s3_key=_KEY, + product="ABI-L2-DSRF", + station=knyc_station, + satellite="goes19", + ingested_at=None, + var_entries=_entries(("ABI-L2-DSRF", "DSR")), + ) + rec = records[0] + assert rec["station"] == "KNYC" + assert rec["delivery"] == "live" + assert "qc_status" in rec + assert "as_of_time" in rec + # The 2i `source` field is REPLACED by `delivery`; no `source` column. + assert "source" not in rec + + def test_build_record_rejects_non_icao_station(self, ds_dsrf_abi) -> None: + """ICAO hook fires: a 3-letter NWS station fails loudly at build.""" + from mostlyright._internal._stations import StationInfo + from mostlyright.core.exceptions import SchemaValidationError + from mostlyright.weather._fetchers._goes_extract import _extract_from_dataset + + bad = StationInfo( + code="NYC", + ghcnh_id="", + icao="NYC", # 3-letter — invalid ICAO + name="bad", + tz="UTC", + latitude=STATION_LAT, + longitude=STATION_LON, + ) + with pytest.raises(SchemaValidationError): + _extract_from_dataset( + ds_dsrf_abi["ds"], + s3_key=_KEY, + product="ABI-L2-DSRF", + station=bad, + satellite="goes19", + ingested_at=None, + var_entries=_entries(("ABI-L2-DSRF", "DSR")), + ) + + def test_units_mismatch_suspect_and_continue( + self, ds_units_mismatch_multivar, knyc_station + ) -> None: + """P2-c (D5 annotate-never-drop) — one var's units mismatch yields a + qc_status='suspect' row AND the other var still produces a clean row; + no UnitsContractError propagates and no variable is dropped.""" + from mostlyright.weather._fetchers._goes_extract import _extract_from_dataset + + records = _extract_from_dataset( + ds_units_mismatch_multivar["ds"], + s3_key=_KEY, + product="ABI-L2-DSIC", + station=knyc_station, + satellite="goes16", + ingested_at=None, + var_entries=_entries(("ABI-L2-DSIC", "LI"), ("ABI-L2-DSIC", "CAPE")), + ) + by_var = {r["variable"]: r for r in records} + # BOTH variables produced rows — nothing dropped, scan not aborted. + assert set(by_var) == {"LI", "CAPE"} + # LI units match -> clean. + assert by_var["LI"]["qc_status"] == "clean" + # CAPE units mismatch -> suspect, with the offending units recorded. + assert by_var["CAPE"]["qc_status"] == "suspect" + assert by_var["CAPE"]["units"] == "WRONG/units" + + def test_units_mismatch_does_not_raise(self, ds_units_mismatch_multivar, knyc_station) -> None: + """The per-variable units mismatch must NOT raise UnitsContractError.""" + from mostlyright.core.exceptions import UnitsContractError + from mostlyright.weather._fetchers._goes_extract import _extract_from_dataset + + try: + _extract_from_dataset( + ds_units_mismatch_multivar["ds"], + s3_key=_KEY, + product="ABI-L2-DSIC", + station=knyc_station, + satellite="goes16", + ingested_at=None, + var_entries=_entries(("ABI-L2-DSIC", "CAPE")), + ) + except UnitsContractError: # pragma: no cover + pytest.fail("units mismatch must annotate-and-continue, not raise") + + def test_3d_profile_emits_one_row_per_level(self, ds_lvtpc_profile, knyc_station) -> None: + """3D profile (LVTPC) emits one row per pressure level.""" + from mostlyright.weather._fetchers._goes_extract import _extract_from_dataset + + records = _extract_from_dataset( + ds_lvtpc_profile["ds"], + s3_key=( + "ABI-L2-LVTPC/2024/167/12/" + "OR_ABI-L2-LVTPC-M6_G16_s20241671201178_e20241671203551_c20241671204010.nc" + ), + product="ABI-L2-LVTPC", + station=knyc_station, + satellite="goes16", + ingested_at="2024-06-15T12:05:00Z", + var_entries=_entries(("ABI-L2-LVTPC", "LVT")), + ) + assert len(records) == ds_lvtpc_profile["n_levels"] + levels = sorted(r["pressure_level_hpa"] for r in records) + assert levels == sorted(ds_lvtpc_profile["pressures"].tolist()) + # each level decodes to ~250 K + for r in records: + assert r["pixel_value"] == pytest.approx(250.0, abs=0.5) + assert r["ingested_at"] == "2024-06-15T12:05:00Z" + + def test_missing_variable_is_skipped_not_aborted(self, ds_dsrf_abi, knyc_station) -> None: + """A registered var absent from the file is skipped (file may predate it).""" + from mostlyright.weather._fetchers._goes_extract import ( + ProductVariable, + _extract_from_dataset, + ) + + # DSR present + a phantom registered var that the file lacks. + phantom = ProductVariable( + product_code="ABI-L2-DSRF", + variable="GHOST", + units_expected="W m-2", + grid_shape_expected=(5424, 5424), + cadence_s=600, + bounds=(0.0, 1400.0), + dqf_good=None, + dqf_variable=None, + is_3d_profile=False, + is_categorical=False, + description="phantom", + tier="v1", + ) + records = _extract_from_dataset( + ds_dsrf_abi["ds"], + s3_key=_KEY, + product="ABI-L2-DSRF", + station=knyc_station, + satellite="goes19", + ingested_at=None, + var_entries=[*_entries(("ABI-L2-DSRF", "DSR")), phantom], + ) + # DSR row produced; GHOST silently skipped (not aborted). + assert [r["variable"] for r in records] == ["DSR"] + + def test_no_projection_variable_raises(self, knyc_station) -> None: + """A file with neither projection var fails loudly (GoesDataCorruptError).""" + from mostlyright.core.exceptions import GoesDataCorruptError + from mostlyright.weather._fetchers._goes_extract import _extract_from_dataset + + ds = xr.Dataset() + with pytest.raises(GoesDataCorruptError): + _extract_from_dataset( + ds, + s3_key=_KEY, + product="ABI-L2-DSRF", + station=knyc_station, + satellite="goes19", + ingested_at=None, + var_entries=_entries(("ABI-L2-DSRF", "DSR")), + ) + + +class TestRegistryHelpersFull: + def test_products_in_tier_and_variables(self) -> None: + from mostlyright.weather._fetchers._goes_extract import ( + products_in_tier, + variables_for_product, + ) + + assert "ABI-L2-DSRF" in products_in_tier("v1") + assert products_in_tier("v3") == ["ABI-L2-LVMPC", "ABI-L2-LVTPC"] + assert variables_for_product("ABI-L2-DSIC") == [ + "CAPE", + "KI", + "LI", + "SI", + "TT", + ] + + def test_read_pixel_dqf_raises_when_declared_but_missing(self, ds_dsrf_abi) -> None: + from mostlyright.core.exceptions import GoesDataCorruptError + from mostlyright.weather._fetchers._goes_extract import ( + PRODUCTS, + _read_pixel_dqf, + ) + + ds = ds_dsrf_abi["ds"].drop_vars("DQF") # remove the declared DQF var + pv = PRODUCTS[("ABI-L2-DSRF", "DSR")] # declares dqf_variable="DQF" + with pytest.raises(GoesDataCorruptError): + _read_pixel_dqf( + ds, + pv, + ds_dsrf_abi["row"], + ds_dsrf_abi["col"], + isel_kwargs={"y": ds_dsrf_abi["row"], "x": ds_dsrf_abi["col"]}, + ) diff --git a/packages/weather/tests/test_satellite_leakage.py b/packages/weather/tests/test_satellite_leakage.py new file mode 100644 index 0000000..fe8d761 --- /dev/null +++ b/packages/weather/tests/test_satellite_leakage.py @@ -0,0 +1,365 @@ +"""Leakage wiring for ``satellite()`` (Phase 25 Wave 3 / 25-04 Task 3). + +Verifies that the public fetcher attaches the leakage overlay columns and +filters ``as_of`` IN-PROCESS on typed datetimes by reusing the existing +``core.temporal`` leakage layer (``KnowledgeView`` / ``assert_no_leakage`` / +``LeakageDetector``) — never a parallel filter, never a lexical string snapshot +(D4). Also asserts the D2 source-identity overlay (per-row ``source`` + +``df.attrs["source"]``, both ``"noaa_goes"``) and the D9 mirror-invariance of +that identity. + +ALL transport is mocked — no live network. +""" + +from __future__ import annotations + +import sys +from datetime import UTC, datetime, timedelta +from typing import Any + +import pytest + +# Guarded pandas import — see test_satellite.py for the Python 3.14 + coverage +# double-numpy-load rationale. +try: + import pandas as pd + + _HAVE_PANDAS = True +except ImportError: # pragma: no cover + pd = None # type: ignore[assignment] + _HAVE_PANDAS = False + +# These tests mock the [satellite] transport (fixtures import ``_goes_s3`` → boto3), +# so they require the optional extra. Skip cleanly in the base CI fast-suite; the +# dedicated satellite-coverage lane installs the extra and runs them. +try: + import boto3 # noqa: F401 + + _HAVE_SATELLITE_DEPS = True +except ImportError: # pragma: no cover + _HAVE_SATELLITE_DEPS = False + +pytestmark = pytest.mark.skipif( + not (_HAVE_PANDAS and _HAVE_SATELLITE_DEPS), + reason="satellite leakage tests require pandas + the [satellite] extra (boto3/s3fs)", +) + +from mostlyright.core.exceptions import LeakageError # noqa: E402 +from mostlyright.core.temporal.leakage import LeakageDetector, assert_no_leakage # noqa: E402 +from mostlyright.core.temporal.timepoint import TimePoint # noqa: E402 +from mostlyright.weather.satellite import satellite # noqa: E402 + + +def _sat_module() -> Any: + return sys.modules["mostlyright.weather.satellite"] + + +def _record( + *, + variable: str = "ACM", + scan_start: str = "2024-06-01T18:00:00Z", + scan_end: str = "2024-06-01T18:05:00Z", + ingested_at: str | None = None, + pixel_value: float | None = 1.0, + units: str = "", +) -> dict[str, Any]: + return { + "station": "KNYC", + "satellite": "goes16", + "product": "ABI-L2-ACMC", + "variable": variable, + "pressure_level_hpa": None, + "scan_start_utc": scan_start, + "scan_end_utc": scan_end, + "delivery": "live", + "source_object_key": "ABI-L2-ACMC/2024/153/18/OR_ABI-L2-ACMC.nc", + "ingested_at": ingested_at, + "pixel_value": pixel_value, + "pixel_dqf": None, + "pixel_row": 1, + "pixel_col": 1, + "units": units, + "station_lat": 40.7789, + "station_lon": -73.9692, + "sat_lon_used": -75.0, + "qc_status": "clean", + "as_of_time": None, + } + + +@pytest.fixture +def mock_transport(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + sat_pkg = _sat_module() + calls: dict[str, Any] = {"records": [_record()], "list": []} + + def fake_list(satellite, product, day, utc_hours, *, mirror="aws", **kw): + calls["list"].append({"mirror": mirror}) + return [("ABI-L2-ACMC/2024/153/18/OR_ABI-L2-ACMC.nc", 500_000)] + + def fake_extract(s3_key, bucket, product, station, *, satellite, size, mirror="aws", **kw): + return list(calls["records"]) + + monkeypatch.setattr(sat_pkg, "list_product_keys", fake_list) + monkeypatch.setattr(sat_pkg, "extract_pixel", fake_extract) + return calls + + +def _kw(**overrides: Any) -> dict[str, Any]: + base = { + "station": "KNYC", + "satellite": "goes16", + "product": "ABI-L2-ACMC", + "start": datetime(2024, 6, 1, tzinfo=UTC), + "end": datetime(2024, 6, 1, tzinfo=UTC), + } + base.update(overrides) + return base + + +# --------------------------------------------------------------------------- +# event_time / knowledge_time mapping (D4 + A3). +# --------------------------------------------------------------------------- +def test_event_time_is_scan_start(mock_transport: dict[str, Any]) -> None: + df = satellite(**_kw()) + expected = pd.Timestamp("2024-06-01T18:00:00Z") + assert (df["event_time"] == expected).all() + + +def test_knowledge_time_uses_ingested_at_when_present(mock_transport: dict[str, Any]) -> None: + mock_transport["records"] = [_record(ingested_at="2024-06-02T00:00:00Z")] + df = satellite(**_kw()) + expected = pd.Timestamp("2024-06-02T00:00:00Z") + assert (df["knowledge_time"] == expected).all() + + +def test_knowledge_time_falls_back_to_fetch_ts(mock_transport: dict[str, Any]) -> None: + # No ingested_at -> knowledge_time is the fetch ts (now). Just assert it is + # tz-aware UTC and close to now (within a generous window). + before = datetime.now(UTC) + df = satellite(**_kw()) + after = datetime.now(UTC) + kt = df["knowledge_time"].iloc[0] + assert kt.tzinfo is not None + assert ( + pd.Timestamp(before) - timedelta(seconds=5) + <= kt + <= pd.Timestamp(after) + timedelta(seconds=5) + ) + + +# --------------------------------------------------------------------------- +# Overlay columns + source identity (D2) + schema dtype. +# --------------------------------------------------------------------------- +def test_overlay_columns_present_and_knowledge_time_tz_aware( + mock_transport: dict[str, Any], +) -> None: + df = satellite(**_kw()) + for col in ("source", "event_time", "knowledge_time", "retrieved_at", "delivery"): + assert col in df.columns, f"missing overlay column {col}" + assert pd.api.types.is_datetime64_any_dtype(df["knowledge_time"]) + assert df["knowledge_time"].dt.tz is not None + + +def test_source_identity_attrs_and_per_row(mock_transport: dict[str, Any]) -> None: + df = satellite(**_kw()) + assert df.attrs["source"] == "noaa_goes" + assert (df["source"] == "noaa_goes").all() + + +def test_delivery_default_live(mock_transport: dict[str, Any]) -> None: + df = satellite(**_kw()) + assert (df["delivery"] == "live").all() + + +def test_assert_no_leakage_accepts_satellite_frame(mock_transport: dict[str, Any]) -> None: + mock_transport["records"] = [_record(ingested_at="2024-06-02T00:00:00Z")] + df = satellite(**_kw()) + # as_of after all knowledge_times -> no leakage. + assert_no_leakage(df, TimePoint("2024-06-03T00:00:00Z")) + + +# --------------------------------------------------------------------------- +# P2-a: the live satellite() path dedups (first-seen-wins) — NOAA reprocesses a +# scan with a new creation-time (c<...>) token but identical scan_start, so two +# keys list + extract to two rows carrying the SAME dedup key. They MUST collapse +# to one row on the live path (mirroring the cache writer), per docs/satellite.md +# ("deduped first-seen-wins") and the merge/satellite 6-tuple invariant. +# --------------------------------------------------------------------------- +def test_live_path_dedups_reprocessed_scan(mock_transport: dict[str, Any]) -> None: + """Two records, identical scan_start, distinct source_object_key creation-time + -> the live DataFrame collapses them to ONE row (first-seen-wins).""" + mock_transport["records"] = [ + _record( + scan_start="2024-06-01T18:00:00Z", + scan_end="2024-06-01T18:05:00Z", + ingested_at="2024-06-02T00:00:00Z", + ), + # SAME scan_start (s<...> token) but a reprocessed creation-time + # (c<...>) in the key — a duplicate scan, must collapse. + { + **_record( + scan_start="2024-06-01T18:00:00Z", + scan_end="2024-06-01T18:05:00Z", + ingested_at="2024-06-02T00:00:00Z", + ), + "source_object_key": ( + "ABI-L2-ACMC/2024/153/18/OR_ABI-L2-ACMC-M6_G16_" + "s20241531800000_e20241531805000_c20241531810999.nc" + ), + }, + ] + df = satellite(**_kw()) + assert len(df) == 1 + # first-seen wins: the surviving row keeps the FIRST record's provenance key. + assert df["source_object_key"].iloc[0].endswith("OR_ABI-L2-ACMC.nc") + + +def test_live_path_distinct_scans_not_collapsed(mock_transport: dict[str, Any]) -> None: + """Distinct scan_start values are NOT collapsed by the live dedup.""" + mock_transport["records"] = [ + _record( + variable="ACM", + scan_start="2024-06-01T18:00:00Z", + scan_end="2024-06-01T18:05:00Z", + ingested_at="2024-06-02T00:00:00Z", + ), + _record( + variable="ACM", + scan_start="2024-06-01T18:10:00Z", + scan_end="2024-06-01T18:15:00Z", + ingested_at="2024-06-02T00:00:00Z", + ), + ] + df = satellite(**_kw()) + assert len(df) == 2 + + +# --------------------------------------------------------------------------- +# D9 mirror-invariant identity. +# --------------------------------------------------------------------------- +def test_mirror_invariant_source_identity(mock_transport: dict[str, Any]) -> None: + df_aws = satellite(**_kw(mirror="aws")) + df_gcp = satellite(**_kw(mirror="gcp")) + assert df_aws.attrs["source"] == "noaa_goes" + assert df_gcp.attrs["source"] == "noaa_goes" + assert (df_aws["source"] == "noaa_goes").all() + assert (df_gcp["source"] == "noaa_goes").all() + # No mirror column on either frame. + assert "mirror" not in df_aws.columns + assert "mirror" not in df_gcp.columns + assert "mirror" not in df_aws.attrs + + +# --------------------------------------------------------------------------- +# P2-b: satellite() runs validate_dataframe(df, 'schema.satellite.v1') so the +# source-identity invariant ACTUALLY executes on the live path. A frame whose +# source-identity is tampered must be REJECTED by the registered validator. +# --------------------------------------------------------------------------- +def test_satellite_runs_schema_validation(mock_transport: dict[str, Any]) -> None: + """A normal pull validates cleanly against the registered schema.""" + sat_pkg = _sat_module() + + calls: dict[str, Any] = {} + real_validate = sat_pkg._validate_against_schema + + def _spy(df: Any) -> None: + calls["called"] = True + real_validate(df) + + monkey = pytest.MonkeyPatch() + monkey.setattr(sat_pkg, "_validate_against_schema", _spy) + try: + mock_transport["records"] = [_record(ingested_at="2024-06-02T00:00:00Z")] + df = satellite(**_kw()) + finally: + monkey.undo() + assert calls.get("called") is True + # The returned frame still carries the byte-faithful string scan times. + assert df["scan_start_utc"].iloc[0] == "2024-06-01T18:00:00Z" + + +def test_satellite_validation_rejects_tampered_source(mock_transport: dict[str, Any]) -> None: + """If the per-row source is corrupted, the registered validator raises.""" + from mostlyright.core.exceptions import SourceMismatchError + + sat_pkg = _sat_module() + orig_assemble = sat_pkg._assemble_dataframe + + def _tamper(rows, *, pd, retrieved_at): # type: ignore[no-untyped-def] + out = orig_assemble(rows, pd=pd, retrieved_at=retrieved_at) + if len(out) > 0: + out["source"] = "noaa_bdp" # corrupt the per-row identity overlay + return out + + monkey = pytest.MonkeyPatch() + monkey.setattr(sat_pkg, "_assemble_dataframe", _tamper) + try: + mock_transport["records"] = [_record(ingested_at="2024-06-02T00:00:00Z")] + with pytest.raises(SourceMismatchError): + satellite(**_kw()) + finally: + monkey.undo() + + +# --------------------------------------------------------------------------- +# Typed as_of filtering via KnowledgeView (D4 — in-process, not lexical). +# --------------------------------------------------------------------------- +def test_as_of_filters_future_knowledge_times(mock_transport: dict[str, Any]) -> None: + mock_transport["records"] = [ + _record(variable="ACM", ingested_at="2024-06-01T00:00:00Z"), + _record( + variable="BCM", + units="1", + ingested_at="2024-06-10T00:00:00Z", + scan_start="2024-06-01T18:10:00Z", + scan_end="2024-06-01T18:15:00Z", + ), + ] + # as_of between the two knowledge_times -> only the earlier row survives. + df = satellite(**_kw(as_of=datetime(2024, 6, 5, tzinfo=UTC))) + assert len(df) == 1 + assert (df["knowledge_time"] <= pd.Timestamp("2024-06-05T00:00:00Z")).all() + + +def test_as_of_none_returns_all_rows(mock_transport: dict[str, Any]) -> None: + mock_transport["records"] = [ + _record(variable="ACM", ingested_at="2024-06-01T00:00:00Z"), + _record( + variable="BCM", + units="1", + ingested_at="2024-06-10T00:00:00Z", + scan_start="2024-06-01T18:10:00Z", + scan_end="2024-06-01T18:15:00Z", + ), + ] + df = satellite(**_kw(as_of=None)) + assert len(df) == 2 + + +def test_as_of_accepts_timepoint(mock_transport: dict[str, Any]) -> None: + mock_transport["records"] = [_record(ingested_at="2024-06-01T00:00:00Z")] + df = satellite(**_kw(as_of=TimePoint("2024-06-05T00:00:00Z"))) + assert len(df) == 1 + + +def test_as_of_accepts_datetime(mock_transport: dict[str, Any]) -> None: + mock_transport["records"] = [_record(ingested_at="2024-06-01T00:00:00Z")] + df = satellite(**_kw(as_of=datetime(2024, 6, 5, tzinfo=UTC))) + assert len(df) == 1 + + +def test_as_of_naive_datetime_rejected_loudly(mock_transport: dict[str, Any]) -> None: + with pytest.raises(ValueError): + satellite(**_kw(as_of=datetime(2024, 6, 5))) # naive — no tzinfo + + +# --------------------------------------------------------------------------- +# LeakageDetector proves the satellite columns wire into the existing layer. +# --------------------------------------------------------------------------- +def test_leakage_detector_raises_on_leaking_frame(mock_transport: dict[str, Any]) -> None: + mock_transport["records"] = [_record(ingested_at="2024-06-10T00:00:00Z")] + # Build the unfiltered frame (as_of=None) then run the loud detector with a + # cutoff BEFORE the row's knowledge_time -> LeakageError. + df = satellite(**_kw(as_of=None)) + with pytest.raises(LeakageError): + LeakageDetector(TimePoint("2024-06-05T00:00:00Z")).check(df) diff --git a/packages/weather/tests/test_satellite_probe.py b/packages/weather/tests/test_satellite_probe.py new file mode 100644 index 0000000..3b82731 --- /dev/null +++ b/packages/weather/tests/test_satellite_probe.py @@ -0,0 +1,392 @@ +"""Tests for the GOES ABI L2 empirical rate-limit / throughput probe (D10 SAT-25-11). + +The probe ACTUALLY hits NOAA only in the ``@pytest.mark.live`` (CI-excluded) +test. Every CI test below is network-free: ``run_probe``'s measurement loop is +exercised with a MOCKED transport (synthetic per-worker latencies/throughputs), +``derive_max_workers`` / ``derive_rate_cap`` are deterministic on a fixed +synthetic curve, and the BOTH-artifacts write (findings + SOURCE-LIMITS satellite +section) + the provenance round-trip helper run against ``tmp_path``. +""" + +from __future__ import annotations + +import pytest +from mostlyright.weather.satellite import _probe + +# ``_probe`` itself is import-clean, but one test below imports ``_backfill`` +# (→ boto3/s3fs). Skip the whole module in the base CI fast-suite (no extra); +# the dedicated satellite-coverage lane installs the extra and runs these. +try: + import boto3 # noqa: F401 + + _HAVE_SATELLITE_DEPS = True +except ImportError: # pragma: no cover - exercised only without the extra + _HAVE_SATELLITE_DEPS = False + +pytestmark = pytest.mark.skipif( + not _HAVE_SATELLITE_DEPS, + reason="probe tests import the backfill module (boto3) — require the [satellite] extra", +) + + +# --------------------------------------------------------------------------- +# Synthetic sweep input — a curve that flattens after N=8 (the knee). +# --------------------------------------------------------------------------- +def _synthetic_measure(n_workers: int) -> _probe.SweepSample: + """Throughput rises to N=8 then flattens; errors appear at N=32.""" + per_worker_Bps = {1: 1_000_000, 4: 3_800_000, 8: 7_000_000, 16: 7_100_000, 32: 7_050_000} + errors = {1: 0, 4: 0, 8: 0, 16: 0, 32: 5} + base_latency = {1: 0.20, 4: 0.22, 8: 0.25, 16: 0.40, 32: 0.95} + lat = base_latency[n_workers] + return _probe.SweepSample( + n_workers=n_workers, + reqs=n_workers * 2, + p50_s=lat, + p95_s=lat * 1.3, + p99_s=lat * 1.6, + throughput_Bps=float(per_worker_Bps[n_workers]), + errors=errors[n_workers], + status_dist={200: n_workers * 2 - errors[n_workers], 503: errors[n_workers]}, + ) + + +# --------------------------------------------------------------------------- +# run_probe measurement loop (mocked transport, network-free) +# --------------------------------------------------------------------------- +class TestRunProbeMocked: + def test_run_probe_produces_per_n_aggregates(self, tmp_path) -> None: + result = _probe.run_probe( + mirror="aws", + out_dir=tmp_path, + sweep=(1, 4, 8, 16, 32), + _measure_fn=_synthetic_measure, + _list_latency_fn=lambda: 0.15, + _single_file_throughput_fn=lambda: 1_200_000.0, + ) + assert isinstance(result, _probe.ProbeResult) + assert [s.n_workers for s in result.sweep] == [1, 4, 8, 16, 32] + for s in result.sweep: + assert s.p50_s > 0 + assert s.p95_s >= s.p50_s + assert s.p99_s >= s.p95_s + assert s.throughput_Bps > 0 + assert result.list_latency_s == 0.15 + assert result.single_file_throughput_Bps == 1_200_000.0 + + +# --------------------------------------------------------------------------- +# derive_* — deterministic on a fixed synthetic curve +# --------------------------------------------------------------------------- +class TestDerive: + def test_derive_max_workers_returns_knee(self, tmp_path) -> None: + result = _probe.run_probe( + mirror="aws", + out_dir=tmp_path, + sweep=(1, 4, 8, 16, 32), + _measure_fn=_synthetic_measure, + _list_latency_fn=lambda: 0.15, + _single_file_throughput_fn=lambda: 1_200_000.0, + ) + # Throughput flattens after N=8 -> knee = 8. + assert _probe.derive_max_workers(result) == 8 + + def test_derive_rate_cap_is_finite_positive(self, tmp_path) -> None: + result = _probe.run_probe( + mirror="aws", + out_dir=tmp_path, + sweep=(1, 4, 8, 16, 32), + _measure_fn=_synthetic_measure, + _list_latency_fn=lambda: 0.15, + _single_file_throughput_fn=lambda: 1_200_000.0, + ) + cap = _probe.derive_rate_cap(result) + assert cap > 0 + assert cap != float("inf") + + def test_derive_is_deterministic(self, tmp_path) -> None: + kwargs = dict( + mirror="aws", + sweep=(1, 4, 8, 16, 32), + _measure_fn=_synthetic_measure, + _list_latency_fn=lambda: 0.15, + _single_file_throughput_fn=lambda: 1_200_000.0, + ) + r1 = _probe.run_probe(out_dir=tmp_path / "a", **kwargs) + r2 = _probe.run_probe(out_dir=tmp_path / "b", **kwargs) + assert _probe.derive_max_workers(r1) == _probe.derive_max_workers(r2) + assert _probe.derive_rate_cap(r1) == _probe.derive_rate_cap(r2) + + +# --------------------------------------------------------------------------- +# FIX-2 BOTH artifacts: findings + SOURCE-LIMITS satellite section +# --------------------------------------------------------------------------- +class TestBothArtifacts: + def test_writes_findings_and_source_limits(self, tmp_path) -> None: + result = _probe.run_probe( + mirror="aws", + out_dir=tmp_path, + sweep=(1, 4, 8, 16, 32), + _measure_fn=_synthetic_measure, + _list_latency_fn=lambda: 0.15, + _single_file_throughput_fn=lambda: 1_200_000.0, + ) + findings = tmp_path / result.findings_filename + source_limits = tmp_path / "SOURCE-LIMITS.md" + assert findings.exists() + assert source_limits.exists() + + ftext = findings.read_text() + # The per-N sweep table lists every swept N. + for n in (1, 4, 8, 16, 32): + assert f"N={n}" in ftext + # The derived recommendation is in the findings artifact. + assert "derive_rate_cap" in ftext + assert "derive_max_workers" in ftext + # A Summary + Scope caveat (mirroring SOURCE-LIMITS structure). + assert "Summary" in ftext + assert "Scope" in ftext + + sltext = source_limits.read_text() + # The SOURCE-LIMITS satellite section records the derived values. + assert "satellite" in sltext.lower() + assert "derive_rate_cap" in sltext + assert "derive_max_workers" in sltext + + def test_provenance_round_trip(self, tmp_path) -> None: + """The reader helper returns the SAME derived values the probe wrote.""" + result = _probe.run_probe( + mirror="aws", + out_dir=tmp_path, + sweep=(1, 4, 8, 16, 32), + _measure_fn=_synthetic_measure, + _list_latency_fn=lambda: 0.15, + _single_file_throughput_fn=lambda: 1_200_000.0, + ) + source_limits = tmp_path / "SOURCE-LIMITS.md" + recovered = _probe.read_source_limits_satellite(source_limits) + assert recovered is not None + assert recovered["max_workers"] == _probe.derive_max_workers(result) + assert abs(recovered["rate_hz"] - _probe.derive_rate_cap(result)) < 1e-6 + + def test_read_source_limits_missing_section_returns_none(self, tmp_path) -> None: + no_sat = tmp_path / "SOURCE-LIMITS.md" + no_sat.write_text("# SOURCE-LIMITS.md\n\nNo satellite section here.\n") + assert _probe.read_source_limits_satellite(no_sat) is None + + def test_read_source_limits_parses_package_seed(self) -> None: + """The package-co-located conservative-pending seed parses (the file the + Task-1 provenance lock reads).""" + from mostlyright.weather.satellite import _backfill + + recorded = _probe.read_source_limits_satellite(_backfill._SOURCE_LIMITS_PATH) + assert recorded is not None + assert recorded["rate_hz"] == 20.0 + assert recorded["max_workers"] == 8 + + +# --------------------------------------------------------------------------- +# P2 (coverage): idempotent SOURCE-LIMITS re-write must not clobber other +# sections — the module's headline safety claim. +# --------------------------------------------------------------------------- +class TestSourceLimitsIdempotency: + def test_rewrite_replaces_satellite_section_preserves_others(self, tmp_path) -> None: + """run_probe twice into a dir whose SOURCE-LIMITS.md already has a + satellite section AND an unrelated '## AWC' section: the satellite + section is REPLACED (not duplicated) and the AWC section survives.""" + out_dir = tmp_path + sl = out_dir / "SOURCE-LIMITS.md" + # Seed a file with an unrelated section the probe must preserve. + sl.write_text( + "# SOURCE-LIMITS.md\n\n" + "## AWC live observations\n\n" + "AWC throttle notes — DO NOT CLOBBER.\n" + ) + kwargs = dict( + mirror="aws", + out_dir=out_dir, + sweep=(1, 4, 8, 16, 32), + _measure_fn=_synthetic_measure, + _list_latency_fn=lambda: 0.15, + _single_file_throughput_fn=lambda: 1_200_000.0, + ) + _probe.run_probe(**kwargs) + _probe.run_probe(**kwargs) # second run exercises the existing-file path + + text = sl.read_text() + # The unrelated section survives both rewrites. + assert "## AWC live observations" in text + assert "DO NOT CLOBBER" in text + # The satellite section appears EXACTLY ONCE (replaced, not duplicated). + assert text.count(_probe._SAT_SECTION_HEADER) == 1 + # The provenance round-trip still works after the rewrite. + recovered = _probe.read_source_limits_satellite(sl) + assert recovered is not None + + def test_strip_satellite_section_keeps_trailing_other_section(self) -> None: + """_strip_satellite_section ends the skip at the next '## ' header.""" + text = ( + "# SOURCE-LIMITS.md\n\n" + f"{_probe._SAT_SECTION_HEADER}\n\nold satellite body\n\n" + "## GHCNh\n\nghcnh body\n" + ) + stripped = _probe._strip_satellite_section(text) + assert _probe._SAT_SECTION_HEADER not in stripped + assert "old satellite body" not in stripped + assert "## GHCNh" in stripped + assert "ghcnh body" in stripped + + +# --------------------------------------------------------------------------- +# P2 (coverage): derive_* degenerate/edge branches + run_probe bad-mirror. +# --------------------------------------------------------------------------- +class TestDeriveEdges: + def test_derive_max_workers_empty_sweep_returns_floor(self) -> None: + result = _probe.ProbeResult( + mirror="aws", + sweep=(), + list_latency_s=0.0, + single_file_throughput_Bps=0.0, + rate_cap_hz=20.0, + max_workers=1, + ) + assert _probe.derive_max_workers(result) == _probe._FLOOR_MAX_WORKERS + + def test_derive_rate_cap_degenerate_p50_returns_floor(self) -> None: + """A knee sample with p50<=0 floors the rate cap at _FLOOR_RATE_HZ.""" + sample = _probe.SweepSample( + n_workers=1, + reqs=2, + p50_s=0.0, # degenerate + p95_s=0.0, + p99_s=0.0, + throughput_Bps=1_000.0, + errors=0, + status_dist={}, + ) + result = _probe.ProbeResult( + mirror="aws", + sweep=(sample,), + list_latency_s=0.0, + single_file_throughput_Bps=0.0, + rate_cap_hz=20.0, + max_workers=1, + ) + assert _probe.derive_rate_cap(result) == _probe._FLOOR_RATE_HZ + + def test_derive_max_workers_breaks_on_errors_before_flatten(self) -> None: + """Throughput keeps RISING but errors appear at N=8 -> the knee is the + last N before the throttle (N=4), exercising the errors>0 break.""" + + def _mk(n, tp, err): + return _probe.SweepSample( + n_workers=n, + reqs=n * 2, + p50_s=0.2, + p95_s=0.3, + p99_s=0.4, + throughput_Bps=float(tp), + errors=err, + status_dist={}, + ) + + result = _probe.ProbeResult( + mirror="aws", + # strictly rising throughput so only the errors>0 branch can break. + sweep=(_mk(1, 1_000_000, 0), _mk(4, 5_000_000, 0), _mk(8, 9_000_000, 3)), + list_latency_s=0.0, + single_file_throughput_Bps=0.0, + rate_cap_hz=20.0, + max_workers=1, + ) + assert _probe.derive_max_workers(result) == 4 + + def test_run_probe_bad_mirror_raises(self, tmp_path) -> None: + with pytest.raises(ValueError): + _probe.run_probe( + mirror="azure", + out_dir=tmp_path, + _measure_fn=_synthetic_measure, + _list_latency_fn=lambda: 0.1, + _single_file_throughput_fn=lambda: 1.0, + ) + + +# --------------------------------------------------------------------------- +# P2 (coverage): read_source_limits_satellite None branches. +# --------------------------------------------------------------------------- +class TestReadSourceLimitsNoneBranches: + def test_missing_file_returns_none(self, tmp_path) -> None: + assert _probe.read_source_limits_satellite(tmp_path / "nope.md") is None + + def test_file_without_satellite_or_markers_returns_none(self, tmp_path) -> None: + p = tmp_path / "SOURCE-LIMITS.md" + p.write_text("# SOURCE-LIMITS.md\n\n## AWC\n\nno satellite here.\n") + assert _probe.read_source_limits_satellite(p) is None + + def test_satellite_text_but_no_derive_markers_returns_none(self, tmp_path) -> None: + p = tmp_path / "SOURCE-LIMITS.md" + # Mentions 'satellite' but lacks the derive_rate_cap marker. + p.write_text("# SOURCE-LIMITS.md\n\n## satellite\n\nprose, no markers.\n") + assert _probe.read_source_limits_satellite(p) is None + + def test_markers_present_but_unparseable_returns_none(self, tmp_path) -> None: + p = tmp_path / "SOURCE-LIMITS.md" + # Contains the marker token + 'satellite' but no numeric values to parse. + p.write_text( + "# SOURCE-LIMITS.md\n\n## satellite\n\n" + "derive_rate_cap mentioned but no Hz number; derive_max_workers too.\n" + ) + assert _probe.read_source_limits_satellite(p) is None + + +# --------------------------------------------------------------------------- +# CLI dispatch (network-free — the actual run is @pytest.mark.live / on-demand) +# --------------------------------------------------------------------------- +class TestProbeCLI: + def test_probe_subcommand_dispatches(self, tmp_path) -> None: + from unittest import mock + + from mostlyright.weather.satellite import __main__ as cli + + captured: dict = {} + + def _fake_run_probe(*, mirror, out_dir, sweep): + captured.update({"mirror": mirror, "out_dir": out_dir, "sweep": sweep}) + return _probe.ProbeResult( + mirror=mirror, + sweep=(), + list_latency_s=0.0, + single_file_throughput_Bps=0.0, + rate_cap_hz=20.0, + max_workers=8, + findings_filename="satellite-throughput-probe.md", + ) + + with mock.patch.object(_probe, "run_probe", _fake_run_probe): + rc = cli.main( + [ + "probe", + "--mirror", + "aws", + "--out", + str(tmp_path), + "--sweep", + "1,4,8", + ] + ) + assert rc == 0 + assert captured["mirror"] == "aws" + assert captured["sweep"] == (1, 4, 8) + + +# --------------------------------------------------------------------------- +# LIVE probe — actually hits NOAA (CI-excluded). +# --------------------------------------------------------------------------- +@pytest.mark.live +class TestProbeLive: + def test_real_noaa_probe_writes_artifacts(self, tmp_path) -> None: + result = _probe.run_probe(mirror="aws", out_dir=tmp_path, sweep=(1, 4, 8)) + assert (tmp_path / result.findings_filename).exists() + assert (tmp_path / "SOURCE-LIMITS.md").exists() + assert result.max_workers >= 1 + assert result.rate_cap_hz > 0 diff --git a/packages/weather/tests/test_satellite_s3.py b/packages/weather/tests/test_satellite_s3.py new file mode 100644 index 0000000..3914661 --- /dev/null +++ b/packages/weather/tests/test_satellite_s3.py @@ -0,0 +1,889 @@ +"""Tests for the GOES ABI L2 whole-file S3/GCS transport (Phase 25 Wave 2). + +Covers the D9 AWS/GCP mirror provider switch, the D3 single-full-object read +primitive (NOT a lazy fs.open handed to xarray), the P2-d size-cap and +grid-shape validation gates, and the available_since clamp — all mocked so the +suite is network-free. Real-S3/GCS round-trips are marked ``@pytest.mark.live`` +and excluded from CI. +""" + +from __future__ import annotations + +import io +import threading +from typing import Any +from unittest import mock + +import pytest +from mostlyright._internal._stations import StationInfo +from mostlyright.core.exceptions import GoesDataCorruptError, GoesS3Error + +# ``_goes_s3`` imports the [satellite] extra (boto3/s3fs/xarray) at module scope. +# In the base CI fast-suite (no extra) skip this whole module cleanly — the +# dedicated satellite-coverage lane installs the extra and runs these. Mirrors +# the guard in test_satellite_extract.py. +try: + from mostlyright.weather._fetchers import _goes_s3 + + _HAVE_SATELLITE_DEPS = True +except ImportError: # pragma: no cover - exercised only without the extra + _goes_s3 = None # type: ignore[assignment] + _HAVE_SATELLITE_DEPS = False + +pytestmark = pytest.mark.skipif( + not _HAVE_SATELLITE_DEPS, + reason="GOES S3 transport tests require the [satellite] optional extra (boto3/s3fs)", +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- +@pytest.fixture +def knyc() -> StationInfo: + return StationInfo( + code="NYC", + ghcnh_id="USW00094728", + icao="KNYC", + name="New York Central Park", + tz="America/New_York", + latitude=40.7790, + longitude=-73.9690, + country="US", + ) + + +# --------------------------------------------------------------------------- +# AWS client / fs construction +# --------------------------------------------------------------------------- +def test_get_s3_client_is_unsigned_us_east_1() -> None: + """The default AWS listing client is boto3 UNSIGNED in us-east-1.""" + import botocore + + _goes_s3._s3_client = None # reset module singleton + captured: dict = {} + + def _fake_client(service: str, *, region_name: str, config) -> object: + captured["service"] = service + captured["region_name"] = region_name + captured["config"] = config + return object() + + with mock.patch.object(_goes_s3.boto3, "client", _fake_client): + _goes_s3._get_s3_client() + + assert captured["service"] == "s3" + assert captured["region_name"] == "us-east-1" + assert captured["config"].signature_version is botocore.UNSIGNED + _goes_s3._s3_client = None + + +def test_get_s3fs_anon_no_fill_cache_and_thread_local() -> None: + """s3fs is anon + default_fill_cache=False and thread-local.""" + made: list[dict] = [] + + class _FakeS3FS: + def __init__(self, **kwargs) -> None: + made.append(kwargs) + + with mock.patch.object(_goes_s3.s3fs, "S3FileSystem", _FakeS3FS): + # clear any thread-local fs from a prior test + if hasattr(_goes_s3._thread_local, "fs_aws"): + del _goes_s3._thread_local.fs_aws + fs1 = _goes_s3._get_s3fs() + fs2 = _goes_s3._get_s3fs() + assert fs1 is fs2 # same instance within a thread + assert made[0] == {"anon": True, "default_fill_cache": False} + + other: list = [] + + def _worker() -> None: + other.append(_goes_s3._get_s3fs()) + + t = threading.Thread(target=_worker) + t.start() + t.join() + assert other[0] is not fs1 # independent per thread + + +# --------------------------------------------------------------------------- +# D9 mirror provider switch +# --------------------------------------------------------------------------- +def test_get_buckets_aws_default() -> None: + assert _goes_s3._get_buckets("aws", "goes16") == "noaa-goes16" + assert _goes_s3._get_buckets("aws", "goes19") == "noaa-goes19" + + +def test_get_buckets_gcp() -> None: + assert _goes_s3._get_buckets("gcp", "goes16") == "gcp-public-data-goes-16" + assert _goes_s3._get_buckets("gcp", "goes19") == "gcp-public-data-goes-19" + + +def test_unknown_mirror_raises_valueerror_listing_supported() -> None: + with pytest.raises(ValueError) as exc: + _goes_s3._get_buckets("azure", "goes16") + msg = str(exc.value) + assert "aws" in msg and "gcp" in msg + + +def test_get_fs_gcp_selects_anon_gcsfs() -> None: + """mirror='gcp' selects gcsfs.GCSFileSystem(token='anon').""" + captured: dict = {} + + class _FakeGCSFS: + def __init__(self, **kwargs) -> None: + captured.update(kwargs) + + fake_gcsfs = mock.MagicMock() + fake_gcsfs.GCSFileSystem = _FakeGCSFS + with mock.patch.dict("sys.modules", {"gcsfs": fake_gcsfs}): + if hasattr(_goes_s3._thread_local, "fs_gcp"): + del _goes_s3._thread_local.fs_gcp + _goes_s3._get_fs("gcp") + assert captured == {"token": "anon"} + + +def test_get_fs_aws_selects_s3fs() -> None: + sentinel = object() + with mock.patch.object(_goes_s3, "_get_s3fs", return_value=sentinel): + assert _goes_s3._get_fs("aws") is sentinel + + +def test_get_fs_unknown_mirror_raises() -> None: + with pytest.raises(ValueError): + _goes_s3._get_fs("nope") + + +# --------------------------------------------------------------------------- +# _RateLimiter +# --------------------------------------------------------------------------- +def test_rate_limiter_serializes_to_interval() -> None: + limiter = _goes_s3._RateLimiter(0.5) + fake_now = {"t": 100.0} + slept: list[float] = [] + + def _monotonic() -> float: + return fake_now["t"] + + def _sleep(s: float) -> None: + slept.append(s) + fake_now["t"] += s + + with ( + mock.patch.object(_goes_s3.time, "monotonic", _monotonic), + mock.patch.object(_goes_s3.time, "sleep", _sleep), + ): + limiter.acquire() # first call: no wait + limiter.acquire() # immediate second call must wait ~0.5 + assert slept and abs(slept[0] - 0.5) < 1e-9 + + +# --------------------------------------------------------------------------- +# _read_full_object — single full-object read, NOT lazy fs.open +# --------------------------------------------------------------------------- +def test_read_full_object_uses_cat_file_once() -> None: + fs = mock.MagicMock() + fs.cat_file.return_value = b"NETCDF-BYTES" + data = _goes_s3._read_full_object(fs, "s3://noaa-goes16/key.nc") + assert data == b"NETCDF-BYTES" + fs.cat_file.assert_called_once_with("s3://noaa-goes16/key.nc") + # No lazy open/seek/range reads after the full read + fs.open.assert_not_called() + + +def test_read_full_object_falls_back_to_open_read() -> None: + """When cat_file is absent, fall back to a single full .read().""" + + class _NoCatFS: + def __init__(self) -> None: + self.read_calls = 0 + + def open(self, uri, mode): + outer = self + + class _Ctx: + def __enter__(self_inner): + return self_inner + + def __exit__(self_inner, *a): + return False + + def read(self_inner): + outer.read_calls += 1 + return b"FALLBACK-BYTES" + + return _Ctx() + + fs = _NoCatFS() + data = _goes_s3._read_full_object(fs, "s3://noaa-goes16/k.nc") + assert data == b"FALLBACK-BYTES" + assert fs.read_calls == 1 + + +# --------------------------------------------------------------------------- +# _open_and_extract — shared pipeline (size cap -> read -> open -> shape -> delegate) +# --------------------------------------------------------------------------- +def _patch_open_dataset(captured: dict): + fake_ds = mock.MagicMock() + + def _open_dataset(buf, **kwargs): + captured["buf"] = buf + captured["kwargs"] = kwargs + return fake_ds + + return _open_dataset, fake_ds + + +def test_open_and_extract_reads_bytesio_not_lazy_file(knyc: StationInfo) -> None: + """xr.open_dataset is handed an in-memory BytesIO, never the lazy fs file.""" + fs = mock.MagicMock() + fs.cat_file.return_value = b"BYTES" + captured: dict = {} + open_ds, _fake_ds = _patch_open_dataset(captured) + + with ( + mock.patch.object(_goes_s3.xr, "open_dataset", open_ds), + mock.patch.object(_goes_s3, "_validate_dataset_shape"), + mock.patch.object(_goes_s3, "_extract_from_dataset", return_value=[{"ok": 1}]) as delegate, + ): + out = _goes_s3._open_and_extract( + fs, + "s3://noaa-goes16/key.nc", + product="ABI-L2-ACMC", + satellite="goes16", + station=knyc, + size=1000, + ingested_at=None, + ) + assert out == [{"ok": 1}] + assert isinstance(captured["buf"], io.BytesIO) + assert captured["kwargs"]["engine"] == "h5netcdf" + assert captured["kwargs"]["mask_and_scale"] is False + assert captured["kwargs"]["decode_times"] is False + delegate.assert_called_once() + # the lazy file object was NEVER handed to xarray + fs.open.assert_not_called() + + +def test_size_cap_rejects_before_read(knyc: StationInfo) -> None: + """An over-cap object is rejected before any object read on either mirror.""" + fs = mock.MagicMock() + huge = _goes_s3._SIZE_CAP_BYTES["ABI-L2-ACMC"] + 1 + with ( + mock.patch.object(_goes_s3, "_read_full_object") as read, + pytest.raises((GoesDataCorruptError, GoesS3Error)), + ): + _goes_s3._open_and_extract( + fs, + "s3://noaa-goes16/key.nc", + product="ABI-L2-ACMC", + satellite="goes16", + station=knyc, + size=huge, + ingested_at=None, + ) + read.assert_not_called() + fs.cat_file.assert_not_called() + + +def test_within_cap_passes_guard(knyc: StationInfo) -> None: + fs = mock.MagicMock() + fs.cat_file.return_value = b"BYTES" + captured: dict = {} + open_ds, _ = _patch_open_dataset(captured) + with ( + mock.patch.object(_goes_s3.xr, "open_dataset", open_ds), + mock.patch.object(_goes_s3, "_validate_dataset_shape"), + mock.patch.object(_goes_s3, "_extract_from_dataset", return_value=[]), + ): + _goes_s3._open_and_extract( + fs, + "s3://noaa-goes16/key.nc", + product="ABI-L2-ACMC", + satellite="goes16", + station=knyc, + size=_goes_s3._SIZE_CAP_BYTES["ABI-L2-ACMC"], + ingested_at=None, + ) + fs.cat_file.assert_called_once() + + +# --------------------------------------------------------------------------- +# _validate_dataset_shape — post-open, pre-pixel-read; DSRF split both mirrors +# --------------------------------------------------------------------------- +def _ds_with_var(var_name: str, dims, shape) -> mock.MagicMock: + ds = mock.MagicMock() + var = mock.MagicMock() + var.dims = dims + var.shape = shape + ds.variables = {var_name: var, "goes_imager_projection": mock.MagicMock()} + ds.__contains__ = lambda self, k: k in ds.variables + ds.__getitem__ = lambda self, k: ds.variables[k] + return ds + + +def test_shape_validation_accepts_conus_acmc() -> None: + ds = _ds_with_var("BCM", ("y", "x"), (1500, 2500)) + # Should not raise + _goes_s3._validate_dataset_shape(ds, "ABI-L2-ACMC", "goes16") + + +def test_shape_validation_rejects_wrong_shape() -> None: + ds = _ds_with_var("BCM", ("y", "x"), (999, 999)) + with pytest.raises(GoesDataCorruptError): + _goes_s3._validate_dataset_shape(ds, "ABI-L2-ACMC", "goes16") + + +def test_shape_validation_dsrf_goes19_fulldisk() -> None: + ds = _ds_with_var("DSR", ("y", "x"), (5424, 5424)) + _goes_s3._validate_dataset_shape(ds, "ABI-L2-DSRF", "goes19") + + +def test_shape_validation_dsrf_goes16_coarse_latlon() -> None: + """goes16 DSRF ships a coarse 326x326 lat/lon grid — accepted on its own sat.""" + ds = mock.MagicMock() + var = mock.MagicMock() + var.dims = ("lat", "lon") + var.shape = (326, 326) + ds.variables = {"DSR": var, "goes_lat_lon_projection": mock.MagicMock()} + ds.__contains__ = lambda self, k: k in ds.variables + ds.__getitem__ = lambda self, k: ds.variables[k] + _goes_s3._validate_dataset_shape(ds, "ABI-L2-DSRF", "goes16") + + +def test_shape_validation_dsrf_swapped_grids_rejected() -> None: + """A goes16-coarse shape on goes19 (and vice-versa) is rejected.""" + ds_coarse = mock.MagicMock() + var = mock.MagicMock() + var.dims = ("lat", "lon") + var.shape = (326, 326) + ds_coarse.variables = {"DSR": var, "goes_lat_lon_projection": mock.MagicMock()} + ds_coarse.__contains__ = lambda self, k: k in ds_coarse.variables + ds_coarse.__getitem__ = lambda self, k: ds_coarse.variables[k] + with pytest.raises(GoesDataCorruptError): + _goes_s3._validate_dataset_shape(ds_coarse, "ABI-L2-DSRF", "goes19") + + +# --------------------------------------------------------------------------- +# P2-3: 3D profile products (ABI-L2-LVMPC / ABI-L2-LVTPC) carry a TRAILING +# pressure axis (layout ``(y, x, pressure)``) — matching the byte-faithful +# extractor (``expected_3d = ("y", "x", "pressure")``) and the real +# ABI-L2-LVMPC/LVTPC files. The registry ``grid_shape_expected`` is the 2D +# spatial grid, so the shape gate must validate the LEADING two (spatial) dims +# (a valid profile file must PASS) while still rejecting a wrong spatial grid. +# --------------------------------------------------------------------------- +def test_shape_validation_3d_profile_passes_spatial_match() -> None: + """A 3D profile var (y x x x pressure) with the right spatial grid passes.""" + # LVMPC registry grid_shape_expected is (300, 500); a real profile file + # carries a TRAILING pressure axis (e.g. 101 levels). + ds = _ds_with_var("LVM", ("y", "x", "pressure"), (300, 500, 101)) + _goes_s3._validate_dataset_shape(ds, "ABI-L2-LVMPC", "goes16") + + +def test_shape_validation_3d_profile_wrong_spatial_rejected() -> None: + """A 3D profile var with the WRONG spatial grid still fails loudly.""" + ds = _ds_with_var("LVM", ("y", "x", "pressure"), (999, 999, 101)) + with pytest.raises(GoesDataCorruptError): + _goes_s3._validate_dataset_shape(ds, "ABI-L2-LVMPC", "goes16") + + +# --------------------------------------------------------------------------- +# P2-3 END-TO-END: a synthetic ``(y, x, pressure)`` LVMPC dataset must BOTH +# pass the ``_goes_s3`` shape gate AND flow through the real +# ``_extract_from_dataset`` 3D-profile loop, emitting one row per pressure +# level (each carrying ``pressure_level_hpa``). A wrong spatial grid still +# raises ``GoesDataCorruptError`` at the gate. +# --------------------------------------------------------------------------- +# KNYC scan-angle reference (mirrors conftest's projection fixtures) so the +# inline ABI grid centers the station pixel. +_STATION_LAT = 40.7789 +_STATION_LON = -73.9692 +_NADIR_LON = -75.0 +_R_EQ = 6378137.0 +_R_POL = 6356752.31414 +_PPH = 35786023.0 + + +def _abi_scan_angles(lat_deg: float, lon_deg: float, nadir_lon: float) -> tuple[float, float]: + """Forward ABI fixed-grid scan angles (PUG Vol5 App A) for the station.""" + import math + + h = _PPH + _R_EQ + e = math.sqrt(1.0 - (_R_POL / _R_EQ) ** 2) + lam0 = math.radians(nadir_lon) + phi = math.radians(lat_deg) + lam = math.radians(lon_deg) + phi_c = math.atan((_R_POL / _R_EQ) ** 2 * math.tan(phi)) + r_c = _R_POL / math.sqrt(1.0 - (e * math.cos(phi_c)) ** 2) + sx = h - r_c * math.cos(phi_c) * math.cos(lam - lam0) + sy = -r_c * math.cos(phi_c) * math.sin(lam - lam0) + sz = r_c * math.sin(phi_c) + y_scan = math.atan(sz / sx) + x_scan = math.asin(-sy / math.sqrt(sx**2 + sy**2 + sz**2)) + return x_scan, y_scan + + +def _real_lvmpc_dataset(n_levels: int, spatial: tuple[int, int]) -> Any: + """Build a REAL xarray LVMPC dataset with TRAILING pressure axis. + + Layout ``(y, x, pressure)`` mirroring the byte-faithful extractor and the + conftest ``ds_lvtpc_profile`` fixture. Carries the ABI imager projection + + scale_factor/add_offset x/y coords (centering KNYC) so the real + ``_extract_from_dataset`` resolves the station pixel. + """ + import numpy as np + import xarray as xr + + ny, nx = spatial + x_scan, y_scan = _abi_scan_angles(_STATION_LAT, _STATION_LON, _NADIR_LON) + row_c, col_c = ny // 2, nx // 2 + x_scale, y_scale = 5.6e-5, -5.6e-5 + x_offset = x_scan - col_c * x_scale + y_offset = y_scan - row_c * y_scale + x_vals = (np.arange(nx) * x_scale + x_offset).astype(np.float64) + y_vals = (np.arange(ny) * y_scale + y_offset).astype(np.float64) + pressures = np.array([1000.0, 850.0, 500.0][:n_levels], dtype=np.float64) + ds = xr.Dataset( + coords={ + "x": xr.DataArray( + x_vals, + dims=("x",), + attrs={"scale_factor": x_scale, "add_offset": x_offset, "units": "rad"}, + ), + "y": xr.DataArray( + y_vals, + dims=("y",), + attrs={"scale_factor": y_scale, "add_offset": y_offset, "units": "rad"}, + ), + "pressure": xr.DataArray(pressures, dims=("pressure",)), + } + ) + ds["goes_imager_projection"] = xr.DataArray( + np.int8(-127), + attrs={ + "grid_mapping_name": "geostationary", + "perspective_point_height": _PPH, + "semi_major_axis": _R_EQ, + "semi_minor_axis": _R_POL, + "longitude_of_projection_origin": _NADIR_LON, + "sweep_angle_axis": "x", + }, + ) + raw = np.full((ny, nx, n_levels), 5000, dtype=np.int16) + ds["LVM"] = xr.DataArray( + raw, + dims=("y", "x", "pressure"), + attrs={ + "scale_factor": 0.01, + "add_offset": 0.0, + "units": "1", + "_FillValue": np.int16(-1), + }, + ) + dqf = np.full((ny, nx), -1, dtype=np.int8) + dqf[row_c, col_c] = 0 + ds["DQF_Overall"] = xr.DataArray(dqf, dims=("y", "x")) + return ds + + +def test_3d_profile_end_to_end_gate_then_extract_one_row_per_level(knyc) -> None: + """A ``(y, x, pressure)`` LVMPC file passes the gate AND emits N profile rows.""" + from mostlyright.weather._fetchers._goes_extract import ( + PRODUCTS, + _extract_from_dataset, + ) + + n_levels = 3 + ds = _real_lvmpc_dataset(n_levels, (300, 500)) + + # (a) The transport shape gate accepts the trailing-pressure layout. + _goes_s3._validate_dataset_shape(ds, "ABI-L2-LVMPC", "goes16") + + # (b) The same dataset flows through the real 3D-profile extractor loop. + records = _extract_from_dataset( + ds, + s3_key=( + "ABI-L2-LVMPC/2024/167/12/" + "OR_ABI-L2-LVMPC-M6_G16_s20241671201178_e20241671203551_c20241671204010.nc" + ), + product="ABI-L2-LVMPC", + station=knyc, + satellite="goes16", + ingested_at=None, + var_entries=[PRODUCTS[("ABI-L2-LVMPC", "LVM")]], + ) + assert len(records) == n_levels + levels = sorted(r["pressure_level_hpa"] for r in records) + assert levels == sorted([1000.0, 850.0, 500.0]) + assert all(r["pressure_level_hpa"] is not None for r in records) + + +def test_3d_profile_end_to_end_wrong_spatial_grid_raises(knyc) -> None: + """A ``(y, x, pressure)`` var with a wrong spatial grid still raises at the gate.""" + ds = _real_lvmpc_dataset(3, (999, 999)) + with pytest.raises(GoesDataCorruptError): + _goes_s3._validate_dataset_shape(ds, "ABI-L2-LVMPC", "goes16") + + +# --------------------------------------------------------------------------- +# list_product_keys — captures Size, available_since clamp, mirror branch +# --------------------------------------------------------------------------- +from datetime import date # noqa: E402 + + +def test_list_product_keys_aws_captures_size() -> None: + page = { + "Contents": [ + {"Key": "ABI-L2-ACMC/2024/167/12/a.nc", "Size": 1234}, + {"Key": "ABI-L2-ACMC/2024/167/12/skip.txt", "Size": 9}, + {"Key": "ABI-L2-ACMC/2024/167/12/b.nc", "Size": 5678}, + ] + } + paginator = mock.MagicMock() + paginator.paginate.return_value = [page] + client = mock.MagicMock() + client.get_paginator.return_value = paginator + + with mock.patch.object(_goes_s3, "_get_s3_client", return_value=client): + results = _goes_s3.list_product_keys( + "goes16", "ABI-L2-ACMC", date(2024, 6, 15), [12], mirror="aws" + ) + keysizes = {(k, s) for (k, s) in results} + assert ("ABI-L2-ACMC/2024/167/12/a.nc", 1234) in keysizes + assert ("ABI-L2-ACMC/2024/167/12/b.nc", 5678) in keysizes + # non-.nc filtered out + assert all(k.endswith(".nc") for (k, _s) in results) + + +def test_list_product_keys_available_since_clamp_both_mirrors() -> None: + """Before available_since, BOTH mirrors return empty without any I/O.""" + client = mock.MagicMock() + fs = mock.MagicMock() + with ( + mock.patch.object(_goes_s3, "_get_s3_client", return_value=client), + mock.patch.object(_goes_s3, "_get_fs", return_value=fs), + ): + aws = _goes_s3.list_product_keys( + "goes16", "ABI-L2-ACMC", date(2017, 1, 1), [12], mirror="aws" + ) + gcp = _goes_s3.list_product_keys( + "goes19", "ABI-L2-ACMC", date(2024, 1, 1), [12], mirror="gcp" + ) + assert aws == [] + assert gcp == [] + client.get_paginator.assert_not_called() + fs.ls.assert_not_called() + + +def test_list_product_keys_gcp_lists_via_gcsfs() -> None: + fs = mock.MagicMock() + fs.ls.return_value = [ + {"name": "gcp-public-data-goes-16/ABI-L2-ACMC/2024/167/12/a.nc", "size": 4321}, + {"name": "gcp-public-data-goes-16/ABI-L2-ACMC/2024/167/12/x.txt", "size": 5}, + ] + with mock.patch.object(_goes_s3, "_get_fs", return_value=fs): + results = _goes_s3.list_product_keys( + "goes16", "ABI-L2-ACMC", date(2024, 6, 15), [12], mirror="gcp" + ) + assert any(s == 4321 for (_k, s) in results) + assert all(k.endswith(".nc") for (k, _s) in results) + fs.ls.assert_called() + + +# --------------------------------------------------------------------------- +# extract_pixel — both mirrors route through the SHARED _open_and_extract +# --------------------------------------------------------------------------- +def test_extract_pixel_aws_routes_through_shared_helper(knyc: StationInfo) -> None: + fs = object() + with ( + mock.patch.object(_goes_s3, "_get_fs", return_value=fs) as get_fs, + mock.patch.object(_goes_s3, "_open_and_extract", return_value=[{"r": 1}]) as shared, + ): + out = _goes_s3.extract_pixel( + "ABI-L2-ACMC/2024/167/12/a.nc", + "noaa-goes16", + "ABI-L2-ACMC", + knyc, + satellite="goes16", + size=1000, + mirror="aws", + ) + assert out == [{"r": 1}] + get_fs.assert_called_once_with("aws") + shared.assert_called_once() + assert shared.call_args.args[0] is fs + + +def test_extract_pixel_gcp_routes_through_same_shared_helper(knyc: StationInfo) -> None: + fs = object() + with ( + mock.patch.object(_goes_s3, "_get_fs", return_value=fs) as get_fs, + mock.patch.object(_goes_s3, "_open_and_extract", return_value=[{"r": 2}]) as shared, + ): + out = _goes_s3.extract_pixel( + "ABI-L2-ACMC/2024/167/12/a.nc", + "gcp-public-data-goes-16", + "ABI-L2-ACMC", + knyc, + satellite="goes16", + size=1000, + mirror="gcp", + ) + assert out == [{"r": 2}] + get_fs.assert_called_once_with("gcp") + shared.assert_called_once() + + +def test_extract_pixel_unknown_mirror_raises(knyc: StationInfo) -> None: + with pytest.raises(ValueError): + _goes_s3.extract_pixel( + "k.nc", + "noaa-goes16", + "ABI-L2-ACMC", + knyc, + satellite="goes16", + size=1, + mirror="azure", + ) + + +def test_extract_pixel_unregistered_product_raises_typed_error(knyc: StationInfo) -> None: + """An unregistered product raises the advertised ProductNotRegisteredError. + + Regression guard: the branch previously passed an unsupported ``product=`` + kwarg to the exception, which raised ``TypeError`` instead of the typed + error, so callers could not catch ``ProductNotRegisteredError``. The check + fires before any S3 I/O, so this needs no network. + """ + from mostlyright.core.exceptions import ProductNotRegisteredError + + with pytest.raises(ProductNotRegisteredError): + _goes_s3.extract_pixel( + "k.nc", + "noaa-goes16", + "ABI-L2-NOPE", + knyc, + satellite="goes16", + size=1, + mirror="aws", + ) + + +# --------------------------------------------------------------------------- +# P2 (coverage): S3/GCS network error-handling — fail-fast, retry, exhaustion. +# These are the loud-failure branches that carry the GoesS3Error contract. All +# network-free: a mock boto3 client/paginator + mock gcsfs raise the relevant +# botocore/OS errors; time.sleep is patched so backoff is asserted, not slept. +# --------------------------------------------------------------------------- +def _client_error(code: str) -> object: + from botocore.exceptions import ClientError + + return ClientError({"Error": {"Code": code, "Message": code}}, "ListObjectsV2") + + +#: A zero-interval rate limiter never sleeps, so a test patching ``time.sleep`` +#: observes ONLY the retry-backoff sleeps (not the limiter's pacing sleep). +#: Guarded for the no-extra fast-suite — the module is skipped there anyway, but +#: this line runs at collection time before ``pytestmark`` takes effect. +_NOOP_LIMITER = _goes_s3._RateLimiter(0.0) if _HAVE_SATELLITE_DEPS else None + + +class TestListAwsErrors: + def test_fail_fast_code_raises_immediately_no_sleep(self) -> None: + """A _S3_FAIL_FAST_CODES ClientError raises GoesS3Error with NO retry/sleep.""" + paginator = mock.MagicMock() + paginator.paginate.side_effect = _client_error("NoSuchKey") + client = mock.MagicMock() + client.get_paginator.return_value = paginator + slept: list[float] = [] + with ( + mock.patch.object(_goes_s3, "_get_s3_client", return_value=client), + mock.patch.object(_goes_s3.time, "sleep", lambda s: slept.append(s)), + pytest.raises(GoesS3Error) as exc, + ): + _goes_s3.list_product_keys( + "goes16", + "ABI-L2-ACMC", + date(2024, 6, 15), + [12], + mirror="aws", + rate_limiter=_NOOP_LIMITER, + ) + assert "NoSuchKey" in str(exc.value) + assert slept == [] # fail-fast: no backoff + + def test_retryable_clienterror_retries_then_raises(self) -> None: + """A retryable ClientError (e.g. SlowDown) retries _MAX_S3_RETRIES then raises.""" + paginator = mock.MagicMock() + paginator.paginate.side_effect = _client_error("SlowDown") + client = mock.MagicMock() + client.get_paginator.return_value = paginator + slept: list[float] = [] + with ( + mock.patch.object(_goes_s3, "_get_s3_client", return_value=client), + mock.patch.object(_goes_s3.time, "sleep", lambda s: slept.append(s)), + pytest.raises(GoesS3Error), + ): + _goes_s3.list_product_keys( + "goes16", + "ABI-L2-ACMC", + date(2024, 6, 15), + [12], + mirror="aws", + rate_limiter=_NOOP_LIMITER, + ) + # Backoff invoked on every attempt except the last (which raises). + assert len(slept) == _goes_s3._MAX_S3_RETRIES - 1 + + def test_botocore_retry_error_retries_then_raises(self) -> None: + """An EndpointConnectionError (in _S3_RETRY_ERRORS) retries then raises.""" + from botocore.exceptions import EndpointConnectionError + + paginator = mock.MagicMock() + paginator.paginate.side_effect = EndpointConnectionError(endpoint_url="https://s3") + client = mock.MagicMock() + client.get_paginator.return_value = paginator + slept: list[float] = [] + with ( + mock.patch.object(_goes_s3, "_get_s3_client", return_value=client), + mock.patch.object(_goes_s3.time, "sleep", lambda s: slept.append(s)), + pytest.raises(GoesS3Error), + ): + _goes_s3.list_product_keys( + "goes16", + "ABI-L2-ACMC", + date(2024, 6, 15), + [12], + mirror="aws", + rate_limiter=_NOOP_LIMITER, + ) + assert len(slept) == _goes_s3._MAX_S3_RETRIES - 1 + + +class TestListGcpErrors: + def test_file_not_found_is_empty_hour_not_error(self) -> None: + """A FileNotFoundError on the GCS prefix is a normal empty-hour gap -> [].""" + fs = mock.MagicMock() + fs.ls.side_effect = FileNotFoundError("no such prefix") + with mock.patch.object(_goes_s3, "_get_fs", return_value=fs): + out = _goes_s3.list_product_keys( + "goes16", "ABI-L2-ACMC", date(2024, 6, 15), [12], mirror="gcp" + ) + assert out == [] + + def test_oserror_retries_then_raises(self) -> None: + """A transient OSError on the GCS listing retries then raises GoesS3Error.""" + fs = mock.MagicMock() + fs.ls.side_effect = OSError("transient gcs") + slept: list[float] = [] + with ( + mock.patch.object(_goes_s3, "_get_fs", return_value=fs), + mock.patch.object(_goes_s3.time, "sleep", lambda s: slept.append(s)), + pytest.raises(GoesS3Error), + ): + _goes_s3.list_product_keys( + "goes16", + "ABI-L2-ACMC", + date(2024, 6, 15), + [12], + mirror="gcp", + rate_limiter=_NOOP_LIMITER, + ) + assert len(slept) == _goes_s3._MAX_S3_RETRIES - 1 + + +class TestExtractPixelRetry: + def test_transient_error_retries_then_succeeds(self, knyc: StationInfo) -> None: + """A ClientError on the first two attempts, success on the third.""" + calls = {"n": 0} + + def _flaky(*a, **k): + calls["n"] += 1 + if calls["n"] < 3: + raise _client_error("SlowDown") + return [{"ok": 1}] + + slept: list[float] = [] + with ( + mock.patch.object(_goes_s3, "_get_fs", return_value=object()), + mock.patch.object(_goes_s3, "_open_and_extract", _flaky), + mock.patch.object(_goes_s3.time, "sleep", lambda s: slept.append(s)), + ): + out = _goes_s3.extract_pixel( + "ABI-L2-ACMC/2024/167/12/a.nc", + "noaa-goes16", + "ABI-L2-ACMC", + knyc, + satellite="goes16", + size=1000, + mirror="aws", + rate_limiter=_NOOP_LIMITER, + ) + assert out == [{"ok": 1}] + assert calls["n"] == 3 + assert len(slept) == 2 # two backoffs before the successful third try + + def test_all_attempts_fail_raises_goess3error(self, knyc: StationInfo) -> None: + """A transient error on EVERY attempt raises GoesS3Error after exhaustion.""" + slept: list[float] = [] + + def _always_fail(*a, **k): + raise _client_error("SlowDown") + + with ( + mock.patch.object(_goes_s3, "_get_fs", return_value=object()), + mock.patch.object(_goes_s3, "_open_and_extract", _always_fail), + mock.patch.object(_goes_s3.time, "sleep", lambda s: slept.append(s)), + pytest.raises(GoesS3Error), + ): + _goes_s3.extract_pixel( + "ABI-L2-ACMC/2024/167/12/a.nc", + "noaa-goes16", + "ABI-L2-ACMC", + knyc, + satellite="goes16", + size=1000, + mirror="aws", + rate_limiter=_NOOP_LIMITER, + ) + assert len(slept) == _goes_s3._MAX_S3_RETRIES - 1 + + +def test_extract_pixel_no_byterange_in_module_source() -> None: + """Grep guard: the transport never byte-range-reads, imports _nwp_idx, or + hands a lazy fs.open object to xarray. + + Inspects executable source lines only (comments/docstrings are stripped) so + the module's own explanatory prose referencing the anti-pattern does not + trip the guard. + """ + import ast + import inspect + + src = inspect.getsource(_goes_s3) + tree = ast.parse(src) + + # No import of _nwp_idx anywhere. + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + assert node.module is None or "_nwp_idx" not in node.module + if isinstance(node, ast.Import): + assert all("_nwp_idx" not in a.name for a in node.names) + + # Strip comments AND docstrings, then assert no byte-range tokens remain in + # the executable code. (ast.unparse drops comments but keeps docstrings, so + # remove leading string-literal expression statements from every scope.) + for node in ast.walk(tree): + body = getattr(node, "body", None) + if ( + isinstance(body, list) + and body + and isinstance(body[0], ast.Expr) + and isinstance(body[0].value, ast.Constant) + and isinstance(body[0].value.value, str) + ): + body.pop(0) + code_only = ast.unparse(tree) + assert "Range:" not in code_only + assert "bytes=" not in code_only + assert "_nwp_idx" not in code_only diff --git a/pyproject.toml b/pyproject.toml index 14faff5..af851c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,6 +104,19 @@ ignore = ["E501"] # line length handled by formatter # F821; suppress only here. "tests/fixtures/drift/capture_drift.py" = ["F821"] "tests/fixtures/parity/measure_ulp_drift.py" = ["F821"] +# Phase 25: the GOES ABI L2 extractor is a BYTE-FAITHFUL port of the 2i +# monorepo (CLAUDE.md: "every comment is load-bearing; do NOT clean up or +# alter constants/comments"). The verbatim NOAA comments/strings carry +# ambiguous Unicode (× ° ± μ) flagged by RUF001/002/003, and the verbatim +# projection arithmetic uses `int(round(...))` flagged by RUF046. Suppress +# ONLY these cosmetic RUF rules for this one ported file so the port stays +# byte-identical; all other lint rules still apply. +"packages/weather/src/mostlyright/weather/_fetchers/_goes_extract.py" = [ + "RUF001", + "RUF002", + "RUF003", + "RUF046", +] [tool.pytest.ini_options] markers = [ diff --git a/schemas/EXPORT_MANIFEST.json b/schemas/EXPORT_MANIFEST.json index 6c34754..d2e3253 100644 --- a/schemas/EXPORT_MANIFEST.json +++ b/schemas/EXPORT_MANIFEST.json @@ -36,6 +36,12 @@ "sha256": "f581bb7f18d896b308240285ec7ea96d51f8f9ef840b48ad4f8b9ee4ad3c18c7", "size_bytes": 1658 }, + { + "gated": false, + "path": "json/schema.satellite.v1.json", + "sha256": "1e6295d9beaa5330027802eebdbb0a692b32b183bc70cea925649decf4d4ec7b", + "size_bytes": 3568 + }, { "gated": false, "path": "json/schema.settlement.cli.v1.json", diff --git a/schemas/json/schema.satellite.v1.json b/schemas/json/schema.satellite.v1.json new file mode 100644 index 0000000..f9a86fb --- /dev/null +++ b/schemas/json/schema.satellite.v1.json @@ -0,0 +1,139 @@ +{ + "$id": "https://mostlyright.dev/schemas/schema.satellite.v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "as_of_time": { + "description": "knowledge-time stamp; nullable (D4)", + "format": "date-time", + "type": [ + "null", + "string" + ] + }, + "delivery": { + "description": "delivery-channel lineage {live,hosted}; NOT source identity (D2)", + "enum": [ + "hosted", + "live" + ], + "type": "string" + }, + "ingested_at": { + "description": "when the bytes were fetched; null for historical backfill", + "format": "date-time", + "type": [ + "null", + "string" + ] + }, + "pixel_col": { + "description": "NetCDF grid col (x) index at the station lat/lon", + "type": "integer" + }, + "pixel_dqf": { + "description": "NOAA DQF integer at the cell; null for variables with no DQF", + "type": [ + "null", + "number" + ] + }, + "pixel_row": { + "description": "NetCDF grid row (y) index at the station lat/lon", + "type": "integer" + }, + "pixel_value": { + "description": "raw-as-reported single grid cell; null ONLY on the file's _FillValue", + "type": [ + "null", + "number" + ] + }, + "pressure_level_hpa": { + "description": "units: hPa \u2014 null for 2D products; populated (part of the dedup key) for 3D profiles", + "type": [ + "null", + "number" + ] + }, + "product": { + "type": "string" + }, + "qc_status": { + "description": "annotate-never-drop verdict (D5 severity-inverted)", + "enum": [ + "clean", + "flagged", + "suspect" + ], + "type": "string" + }, + "sat_lon_used": { + "description": "units: degrees_east \u2014 sub-satellite longitude read from goes_imager_projection", + "type": "number" + }, + "satellite": { + "enum": [ + "goes16", + "goes19" + ], + "type": "string" + }, + "scan_end_utc": { + "description": "UTC scan end from the NetCDF filename _e token; >= scan_start_utc", + "format": "date-time", + "type": "string" + }, + "scan_start_utc": { + "description": "UTC scan start from the NetCDF filename _s token (event-time, D4)", + "format": "date-time", + "type": "string" + }, + "source": { + "description": "per-row source-identity overlay == df.attrs['source'] == 'noaa_goes' (D2)", + "type": "string" + }, + "source_object_key": { + "description": "full S3/GCS object key the row was extracted from (provenance)", + "type": "string" + }, + "station": { + "type": "string" + }, + "station_lat": { + "description": "units: degrees_north \u2014 station latitude copied at ingest time (provenance)", + "type": "number" + }, + "station_lon": { + "description": "units: degrees_east \u2014 station longitude copied at ingest time (provenance)", + "type": "number" + }, + "units": { + "description": "physical units from the NetCDF variable units attr", + "type": "string" + }, + "variable": { + "type": "string" + } + }, + "required": [ + "delivery", + "pixel_col", + "pixel_row", + "product", + "qc_status", + "sat_lon_used", + "satellite", + "scan_end_utc", + "scan_start_utc", + "source", + "source_object_key", + "station", + "station_lat", + "station_lon", + "units", + "variable" + ], + "title": "schema.satellite.v1", + "type": "object", + "version": "v1" +} diff --git a/scripts/export_schemas.py b/scripts/export_schemas.py index ac2a360..43dbaad 100644 --- a/scripts/export_schemas.py +++ b/scripts/export_schemas.py @@ -95,6 +95,7 @@ "schema.observation_ledger.v1", "schema.observation_qc.v1", "schema.forecast_nwp.v1", + "schema.satellite.v1", ) @@ -257,6 +258,7 @@ def _build_group_a_schemas() -> list[_OutputFile]: ObservationLedgerSchema, ObservationQCSchema, ObservationSchema, + SatelliteSchema, SettlementSchema, StationForecastSchema, ) @@ -269,6 +271,7 @@ def _build_group_a_schemas() -> list[_OutputFile]: ObservationLedgerSchema.schema_id: ObservationLedgerSchema, ObservationQCSchema.schema_id: ObservationQCSchema, NwpForecastSchema.schema_id: NwpForecastSchema, + SatelliteSchema.schema_id: SatelliteSchema, } out: list[_OutputFile] = [] for schema_id in _GROUP_A_SCHEMA_IDS: diff --git a/scripts/satellite_coverage.sh b/scripts/satellite_coverage.sh new file mode 100755 index 0000000..c4759d5 --- /dev/null +++ b/scripts/satellite_coverage.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Phase 25 (P2 verification-completeness fix): provable >=80% LINE coverage on +# the four satellite modules that the default branch-coverage gate CANNOT +# measure. +# +# WHY a separate lane: +# The root pyproject.toml [tool.coverage.run] sets ``branch = true``, which +# forces coverage's C tracer. Any subprocess that imports numpy/pandas under +# the C tracer raises ``numpy: cannot load module more than once per +# process`` — so test_satellite_extract / _s3 / _cache / schema could not be +# coverage-measured at all, and the >=80% number was inferred, not proven. +# +# HOW this works: +# - ``COVERAGE_CORE=sysmon`` selects the sys.monitoring backend instead of +# the C tracer. sysmon does not trigger the numpy single-load error. +# - ``.coveragerc-satellite`` sets ``branch = false`` (sysmon cannot measure +# branches in the pinned coverage version; line coverage is what the +# CLAUDE.md ">=80% on new code" gate requires) and uses PATH-based +# ``include`` globs (the modules import lazily inside test bodies; dotted +# ``source`` does not attach reliably under sysmon). +# - A SINGLE pytest process runs all four satellite test files so numpy is +# imported exactly once. +# +# Records the measured per-module numbers so the gate is provable, not +# inferred. Exits non-zero if total line coverage < 80% (fail_under in the rc). +set -euo pipefail + +cd "$(dirname "$0")/.." + +rm -f .coverage + +COVERAGE_CORE=sysmon uv run coverage run --rcfile=.coveragerc-satellite -m pytest \ + -m "not live" \ + packages/weather/tests/test_satellite_extract.py \ + packages/weather/tests/test_satellite_s3.py \ + packages/weather/tests/test_satellite_cache.py \ + packages/core/tests/test_satellite_schema.py \ + -p no:cov -q -o addopts="" + +uv run coverage report --rcfile=.coveragerc-satellite diff --git a/uv.lock b/uv.lock index 7d089a5..8dea959 100644 --- a/uv.lock +++ b/uv.lock @@ -5,11 +5,14 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version < '3.12' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] @@ -33,6 +36,173 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" }, ] +[[package]] +name = "aiobotocore" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aioitertools" }, + { name = "botocore" }, + { name = "jmespath" }, + { name = "multidict" }, + { name = "python-dateutil" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/75/42cce839c2ec263ff74b10b650fe36b066fbb124cbee6f247eac0983e1ab/aiobotocore-3.7.0.tar.gz", hash = "sha256:c64d871ed5491a6571948dd48eabd185b46c6c23b64e3afd0c059fc7593ada30", size = 127054, upload-time = "2026-05-09T10:02:52.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/5f/85535dfb3cfd6442d66d1df1694062c5d6df02f895329e7e120b2a3d2b8b/aiobotocore-3.7.0-py3-none-any.whl", hash = "sha256:680bde7c64679a821a9312641b759d9497f790ba8b2e88c6959e6273ee765b8e", size = 89539, upload-time = "2026-05-09T10:02:50.389Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, + { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, + { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, + { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, + { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, + { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, + { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, + { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, + { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, + { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, + { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, + { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, +] + +[[package]] +name = "aioitertools" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322, upload-time = "2025-11-06T22:17:07.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182, upload-time = "2025-11-06T22:17:06.502Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + [[package]] name = "alabaster" version = "1.0.0" @@ -86,6 +256,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, ] +[[package]] +name = "boto3" +version = "1.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/65/47670987f2f9e181397872c7ee6415b7b95156d711b7eab6c55f66e575bc/boto3-1.43.0.tar.gz", hash = "sha256:80d44a943ef90aba7958ab31d30c155c198acc8a9581b5846b3878b2c8951086", size = 113143, upload-time = "2026-04-29T22:07:49.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/a0/3e6a0b1c1ea6bec76f71473727ef27abf3cd40e9709b3ebcbfbcfaae6f79/boto3-1.43.0-py3-none-any.whl", hash = "sha256:8ebe03754a4b73a5cb6ec2f14cca03ac33bd4760d0adea53da4724845130258b", size = 140497, upload-time = "2026-04-29T22:07:46.216Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/79/2f4be1896db3db7ccf44504253a175d56b6bd6b669619edc5147d1aa21ea/botocore-1.43.0.tar.gz", hash = "sha256:e933b31a2d644253e1d029d7d39e99ba41b87e29300534f189744cc438cdf928", size = 15286817, upload-time = "2026-04-29T22:07:31.723Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/4b/afc1fef8a43bafb139f57f73bbd70df82807af5934321e8112ae50668827/botocore-1.43.0-py3-none-any.whl", hash = "sha256:cc5b15eaec3c6eac05d8012cb5ef17ebe891beb88a16ca13c374bfaece1241e6", size = 14970102, upload-time = "2026-04-29T22:07:27Z" }, +] + [[package]] name = "certifi" version = "2026.5.20" @@ -403,6 +601,71 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -457,6 +720,120 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/ff/76dd547e129206899e4e26446c3ca7aeaff948c31b05250e9b8690e76883/findlibs-0.1.2-py3-none-any.whl", hash = "sha256:5348bbc7055d2a505962576c2e285b6c0aae6d749f82ba71296e7d41336e66e8", size = 10707, upload-time = "2025-07-28T09:15:02.733Z" }, ] +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/a1/ae4e3e5003468d6391d2c77b6fa1cd73bd5d13511d81c642d7b28ac90ed4/fsspec-2026.6.0.tar.gz", hash = "sha256:f5bac145310fe30e16e1471bd6840b2d990d609e872251d7e674241822abf01a", size = 313646, upload-time = "2026-06-16T01:57:28.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, +] + [[package]] name = "furo" version = "2025.12.19" @@ -473,6 +850,258 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/b2/50e9b292b5cac13e9e81272c7171301abc753a60460d21505b606e15cf21/furo-2025.12.19-py3-none-any.whl", hash = "sha256:bb0ead5309f9500130665a26bee87693c41ce4dbdff864dbfb6b0dae4673d24f", size = 339262, upload-time = "2025-12-19T17:34:38.905Z" }, ] +[[package]] +name = "gcsfs" +version = "2026.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "decorator" }, + { name = "fsspec" }, + { name = "google-auth" }, + { name = "google-auth-oauthlib" }, + { name = "google-cloud-storage" }, + { name = "google-cloud-storage-control" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/ad/36777b2030b96d32f5c557606ac11bf5d0e152620fc15055bd1e30194037/gcsfs-2026.6.0.tar.gz", hash = "sha256:bfb1f912b3f51006b00bcd5fcef915214cb51f8b892a3974178430a55990ba3f", size = 1007987, upload-time = "2026-06-18T03:16:08.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/43/a0096d96d3271640bbda056b9d22249be141a54509859b99002ee6149a01/gcsfs-2026.6.0-py3-none-any.whl", hash = "sha256:4a81fe40fbdef450aeaacb534b42c04059f8f3b080e3e169137247a5bb023464", size = 89629, upload-time = "2026-06-18T03:16:07.407Z" }, +] + +[[package]] +name = "google-api-core" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/22/155cadf1d49272a9cf48f3168c0f3874fa13397297e611a5ea00cd093880/google_api_core-2.31.0.tar.gz", hash = "sha256:2be84ee0f584c48e6bde1b36766e23348b361fb7e55e56135fc76ce1c397f9c2", size = 176492, upload-time = "2026-06-03T14:52:17.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/40/9bdbb60b03a332bd45acb8703da08bbc27d991d35286b62e42acc86d243a/google_api_core-2.31.0-py3-none-any.whl", hash = "sha256:ef79fb3784c71cbac89cbd03301ba0c8fb8ad2aa95d7f9204dd9628f7adf59ab", size = 173102, upload-time = "2026-06-03T14:51:26.729Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, + { name = "grpcio-status" }, +] + +[[package]] +name = "google-auth" +version = "2.55.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/1c/70b23fc52b2bb3c70b379f3bd05c4a60ab3a873e30c6bd21c57e0154848a/google_auth-2.55.0.tar.gz", hash = "sha256:fcd3a130f575fa36403d38774af1c64a4fbfbca09215f0589d2372b5119697cb", size = 349379, upload-time = "2026-06-15T22:33:16.466Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/71/c0321dc6d63d99946da45f7c06299b934e4f7f7da5c4f14d101bcb39adf1/google_auth-2.55.0-py3-none-any.whl", hash = "sha256:a17cef9dedf98c4ebae2fb0c48c8f75952c877cbc2efe09f329ef16c2783d88a", size = 252400, upload-time = "2026-06-15T22:33:14.992Z" }, +] + +[[package]] +name = "google-auth-oauthlib" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "requests-oauthlib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/18/90c7fac516e63cf2058166fce0c88c353647c677b51cc036c09c49bb5cbb/google_auth_oauthlib-1.4.0.tar.gz", hash = "sha256:18b5e28880eb8eba9065c436becdc0ee8e4b59117a73a510679c82f70cd363d2", size = 21675, upload-time = "2026-05-07T08:03:47.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/d3/d7dff0d58a9e9244b48044bfb6a898bfcc8ecc42e0031d1bebc695344725/google_auth_oauthlib-1.4.0-py3-none-any.whl", hash = "sha256:251314f213a9ee46a5ae73988e84fd7cca8bb68e7ecf4bfd45940f9e7f51d070", size = 19261, upload-time = "2026-05-07T08:02:13.798Z" }, +] + +[[package]] +name = "google-cloud-core" +version = "2.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/dd/1eef226e470369b26824a505c34482c0b493bc35fe8e0c6b003b5feca21a/google_cloud_core-2.6.0.tar.gz", hash = "sha256:e76149739f90fac1fc6757c09f47eaccb3145b54adbd7759b0f7c4b235f46c83", size = 36001, upload-time = "2026-05-07T08:04:04.124Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/4a/98da8930ab109c73d9a5d13782a9ebb81ea8c111f6d534a567b71d23e52b/google_cloud_core-2.6.0-py3-none-any.whl", hash = "sha256:6d63ac8e5eca6d9e4319d0a1e2265fadcd7f1049904378caecfa01cf52dd869e", size = 29390, upload-time = "2026-05-07T08:02:34.672Z" }, +] + +[[package]] +name = "google-cloud-storage" +version = "3.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-cloud-core" }, + { name = "google-crc32c" }, + { name = "google-resumable-media" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/72/86f94e1639a8bcd9d33e8e01b49afcaa1c3a13bda7683c681717e0901e15/google_cloud_storage-3.12.0.tar.gz", hash = "sha256:03ae9847c6babb368f35f054126b8a08cbc0e3266efb990eb17b9926a45cf3be", size = 17338620, upload-time = "2026-06-12T18:03:29.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bd/a89eaebd2f9db5f92ddcc8e4f23c266be1dbd11058bb83451d8dd029f34c/google_cloud_storage-3.12.0-py3-none-any.whl", hash = "sha256:3880773754ddf7c27567b04e2a4d193950b6b99429f37b9097d873686e95b09c", size = 340605, upload-time = "2026-06-12T18:03:12.677Z" }, +] + +[[package]] +name = "google-cloud-storage-control" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpc-google-iam-v1" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/ae/707995271f77e3c1da715c740160f98f87a79a5c601b28d17c4d2500c037/google_cloud_storage_control-1.12.0.tar.gz", hash = "sha256:49090d03532c0c84c6246a5fd490c31fd4bbffb4c7771c0a6744ec23a194a5f6", size = 137036, upload-time = "2026-06-03T16:14:00.921Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/ba/f187f30fb2c8bb5dbb0de0fcb0dec2f8fab0b782ab42facc83ea02628f1b/google_cloud_storage_control-1.12.0-py3-none-any.whl", hash = "sha256:20f7f1252fa5635d47e12f746aa69d719e31eb9405cbbd14cdfba317db27d421", size = 102205, upload-time = "2026-06-03T16:12:43.266Z" }, +] + +[[package]] +name = "google-crc32c" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/ef/21ccfaab3d5078d41efe8612e0ed0bfc9ce22475de074162a91a25f7980d/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8", size = 31298, upload-time = "2025-12-16T00:20:32.241Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b8/f8413d3f4b676136e965e764ceedec904fe38ae8de0cdc52a12d8eb1096e/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7", size = 30872, upload-time = "2025-12-16T00:33:58.785Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fd/33aa4ec62b290477181c55bb1c9302c9698c58c0ce9a6ab4874abc8b0d60/google_crc32c-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15", size = 33243, upload-time = "2025-12-16T00:40:21.46Z" }, + { url = "https://files.pythonhosted.org/packages/71/03/4820b3bd99c9653d1a5210cb32f9ba4da9681619b4d35b6a052432df4773/google_crc32c-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a", size = 33608, upload-time = "2025-12-16T00:40:22.204Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/acf61476a11437bf9733fb2f70599b1ced11ec7ed9ea760fdd9a77d0c619/google_crc32c-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2", size = 34439, upload-time = "2025-12-16T00:35:20.458Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, + { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, + { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, + { url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301, upload-time = "2025-12-16T00:24:48.527Z" }, + { url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868, upload-time = "2025-12-16T00:48:12.163Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381, upload-time = "2025-12-16T00:40:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734, upload-time = "2025-12-16T00:40:27.028Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878, upload-time = "2025-12-16T00:35:23.142Z" }, + { url = "https://files.pythonhosted.org/packages/52/c5/c171e4d8c44fec1422d801a6d2e5d7ddabd733eeda505c79730ee9607f07/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93", size = 28615, upload-time = "2025-12-16T00:40:29.298Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" }, +] + +[[package]] +name = "google-resumable-media" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-crc32c" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/f8/1ca5781d6be9cb9f73f7d40f4958c4bd1226a60598e3e39e1d6aaf838c4b/google_resumable_media-2.10.0.tar.gz", hash = "sha256:e324bc9d0fdae4c52a08ae90456edc4e71ece858399e1217ac0eb3a51d6bc6ee", size = 2164570, upload-time = "2026-06-03T16:14:26.103Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/d8/00c6854ac1512bb9eaf13bd3f8f28222f7674947fc510a4ff7616f2efc80/google_resumable_media-2.10.0-py3-none-any.whl", hash = "sha256:88152884bee37b2bf36a0ab81ad8c7fd12212c9803dd981d77c1b35b02d34e7c", size = 81533, upload-time = "2026-06-03T16:13:12.51Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, +] + +[[package]] +name = "grpc-google-iam-v1" +version = "0.14.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos", extra = ["grpc"] }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/4f/d098419ad0bfc06c9ce440575f05aa22d8973b6c276e86ac7890093d3c37/grpc_google_iam_v1-0.14.4.tar.gz", hash = "sha256:392b3796947ed6334e61171d9ab06bf7eb357f554e5fc7556ad7aab6d0e17038", size = 23706, upload-time = "2026-04-01T01:57:49.813Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/22/c2dd50c09bf679bd38173656cd4402d2511e563b33bc88f90009cf50613c/grpc_google_iam_v1-0.14.4-py3-none-any.whl", hash = "sha256:412facc320fcbd94034b4df3d557662051d4d8adfa86e0ddb4dca70a3f739964", size = 32675, upload-time = "2026-04-01T01:57:47.69Z" }, +] + +[[package]] +name = "grpcio" +version = "1.81.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/b5/1ff353970a87eda4c98251e34d2dfd214abd4982dc89119c9252a2a482d2/grpcio-1.81.1.tar.gz", hash = "sha256:6fa10a767143a5e82e8eaab53918af0cd8909a57a27f8cb2288b80a613ac671b", size = 13026582, upload-time = "2026-06-11T12:46:51.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/ea/1c2fa386b718ff493225e61cfc052ef400b4d6ffc54cbe261026432624b5/grpcio-1.81.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:d71d30f2d92f67d944631c523713934fee37292469e182ebcd2c1dd8a64ce53f", size = 6093112, upload-time = "2026-06-11T12:44:52.131Z" }, + { url = "https://files.pythonhosted.org/packages/2b/18/acf45fa8bd1bc5d7b0c2fd3dc4c209379fbd5bb396b440b68a83342226b7/grpcio-1.81.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:b137f4bf3ada9dc44d411478decc6ff09a79ed30b306cd2abaa98408c3588137", size = 12074277, upload-time = "2026-06-11T12:44:55.354Z" }, + { url = "https://files.pythonhosted.org/packages/48/d7/ee86a60699b7db039f772a2c4a7e4facc7138984ff42c0130933a0063884/grpcio-1.81.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a3acb384427816dd5d470f47e62137b87f74da694faa8a50147012cf40df276a", size = 6640348, upload-time = "2026-06-11T12:44:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/26/ee/d2de5e47378ffc207d476c230fea3be4d2601edbce9995f4fe45535d4896/grpcio-1.81.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f9a0ebbe45c29b5e5866593c12b78bd9035f0f0f0d4bc8361680cd580d99db49", size = 7331842, upload-time = "2026-06-11T12:45:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/23/d6/abeda5c2b896a0b341584fe5ac411bbf72e197a9a374c355fb90965e08d2/grpcio-1.81.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a37165cc80b1a368384b383e63a4c38116a10467ae44c904d2d7468c4470ec2", size = 6842229, upload-time = "2026-06-11T12:45:04.76Z" }, + { url = "https://files.pythonhosted.org/packages/10/1c/1f0da7d590b4aeee006826ba568d0e419ca14b23e18f901a3da3e9fba613/grpcio-1.81.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6282caffb41ec326d4cb67ca9cf53b739d1b2f975a2acb498c7418e9f7d9a416", size = 7446096, upload-time = "2026-06-11T12:45:07.499Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/5c505d508f7c887aa7982d21443a4126597c80d34b0bcf40f9cec576d7f3/grpcio-1.81.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a35009284d0d3d5c2c9601c164a911b8b4331608d98a9a66d47d97bb2f522b70", size = 8445238, upload-time = "2026-06-11T12:45:10.243Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b2/524847365122ee509ca17bcc4e092198b700e94af7bfd5bb5e6dd9f3ee66/grpcio-1.81.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1b22c80559854b789a01fd89e8929b3798a156c0829b5282a8939f33ad4115ad", size = 7873989, upload-time = "2026-06-11T12:45:13.102Z" }, + { url = "https://files.pythonhosted.org/packages/18/fa/07c037c50b006909d1d13a5848774f8aa7b242f70dc03a035c64eea0e6db/grpcio-1.81.1-cp311-cp311-win32.whl", hash = "sha256:428bec0161b48d8cf583c068591bc0016d0d9cfff52462b72b3884861ea768c5", size = 4202223, upload-time = "2026-06-11T12:45:16.166Z" }, + { url = "https://files.pythonhosted.org/packages/41/ed/6bff15376920942fac6b95b9802752b837437172c9e8fc2d3170546b89cc/grpcio-1.81.1-cp311-cp311-win_amd64.whl", hash = "sha256:30e825f6848d9f18bba350ed6c75c1b02a0b5184474a31db9a32b1fa66fd8c79", size = 4941303, upload-time = "2026-06-11T12:45:18.724Z" }, + { url = "https://files.pythonhosted.org/packages/85/07/9a979c81738863a738dc23d65177056e71fbb2db817740ed870b33434e7a/grpcio-1.81.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:8b39472beafc0bdcafc4c8c73ad082ebfdb449d566897a61e7acb4fa88089115", size = 6053264, upload-time = "2026-06-11T12:45:21.017Z" }, + { url = "https://files.pythonhosted.org/packages/75/95/539706ca0d3bd40dbad583dc56fd883da941f37556b629132da5762781b9/grpcio-1.81.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:12b7524c88d4026d3dcb7b0ebe16b6714f3b4af402ddd0f0639ab064a00c87c3", size = 12052560, upload-time = "2026-06-11T12:45:23.652Z" }, + { url = "https://files.pythonhosted.org/packages/e0/44/f257b7e0bd69c93b06c6cb8ac8d1b901ccb42bedabd83c1a4c77a71f8810/grpcio-1.81.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1e123f9b37edb8375fd74130d1f69c944bbf0a7b06761ae7211154b8759e94d2", size = 6595983, upload-time = "2026-06-11T12:45:26.963Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f3/19782aa04c960968bef8c5539329d8e3bbc3364e2e46d19eb5e5cc5e43b7/grpcio-1.81.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2c2e2ae6867c2966b8daccc836d54a13218e0007e9a490aeb81dd05be64d22d7", size = 7303455, upload-time = "2026-06-11T12:45:29.707Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8c/dea020b6d91508cd84463917a63149ec196ee7db505d032ae43fcb3303b9/grpcio-1.81.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:766bc7c9a9c340342f4c864ccbda8e78111e4751f13b895812b9c148fb79e9d0", size = 6809167, upload-time = "2026-06-11T12:45:32.52Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/3030dd940408083bd32cd95d634777a71605ade4887154d93e8a89244946/grpcio-1.81.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b259a04a737cb3496be0901328eb8b7552ed8df4865d8c8f1cf1bffcfc0776a3", size = 7412536, upload-time = "2026-06-11T12:45:35.403Z" }, + { url = "https://files.pythonhosted.org/packages/e0/dd/1172a9e42b168edcafefad6115346ef619a3fc02158bb170e66ced24bcdd/grpcio-1.81.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:85b10a45b8993d195c4f3ff57025b8d1e11834909ee475c403bfa60cb4caefaf", size = 8408276, upload-time = "2026-06-11T12:45:37.78Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/71437c7f3596e5246155c515852795a85a1a8d228190212432b13b97a95d/grpcio-1.81.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8ea1936c26b99999b27479853039a7f34713f56c49375ad52b38535ec93a796c", size = 7849660, upload-time = "2026-06-11T12:45:40.627Z" }, + { url = "https://files.pythonhosted.org/packages/65/40/7debc0da45d2efebafb82da75644be347497fe4ee250514b8cd3b86ae8bf/grpcio-1.81.1-cp312-cp312-win32.whl", hash = "sha256:a185a04039df6cae8648bc8ab6d6fde7bf94f7188ecf7828e76ac52eef1e41d6", size = 4185819, upload-time = "2026-06-11T12:45:43.027Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b9/8fe3ba5ed462067774ebc1f9c7f26aa7ebcc280ddd476be107153de1339e/grpcio-1.81.1-cp312-cp312-win_amd64.whl", hash = "sha256:3ad74f8bb1a18963914c5452d289422830b39459e8776ebbcd207be1fbfb1d94", size = 4930461, upload-time = "2026-06-11T12:45:45.775Z" }, + { url = "https://files.pythonhosted.org/packages/7a/42/dcc2e4b600538ef18327c0839d56b7d3c3812337c5d710df5877dbb39b1e/grpcio-1.81.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b10e1ff4756ed27d5a29d7fc79cfce7ef1ff56ad20025b89bac7cf79e09abbbe", size = 6054466, upload-time = "2026-06-11T12:45:48.43Z" }, + { url = "https://files.pythonhosted.org/packages/7b/4a/a36e03210183a8a7d4c80c3936acee679f4bd77d5861f369db47b2cc5f05/grpcio-1.81.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:819edbdcb42ab8598b494bcf0222684bbb7a3c772bd1b1f0be7e029a6063c28e", size = 12048795, upload-time = "2026-06-11T12:45:54.011Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d5/d68e30b29098f63beab6fe501100fe82674ff142b32c672532da86a99b3a/grpcio-1.81.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5bf2dc311127d91230cc79b92188c082634a06cf66c5234db49a43b910183b0", size = 6599094, upload-time = "2026-06-11T12:45:57.799Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b3/e837954d279754f638a11cca5dcf6b24a005efb398984cefaf7735945a54/grpcio-1.81.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e8ca6a1fcdb2943c9cbc1804a1baf3acb6071d72a471591678ded84218006e14", size = 7307182, upload-time = "2026-06-11T12:46:00.568Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/b47957057e729adc6cdf519a47f8be2562b7140e280f1418443eb4022192/grpcio-1.81.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e64dd101d380a115cc5a0c7856788adb535f1a4e21fc543775602f8be95180ae", size = 6810962, upload-time = "2026-06-11T12:46:03.312Z" }, + { url = "https://files.pythonhosted.org/packages/40/26/569868e364e05b19ec8f969da53d230bcd89c962cd198f7c29943155c4d3/grpcio-1.81.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:98a07f9bf591e3a8919797bee1c53f026ba4acd587e5a4404c8e57c9ec36b2a5", size = 7415698, upload-time = "2026-06-11T12:46:06.005Z" }, + { url = "https://files.pythonhosted.org/packages/36/0c/5440a0582cb5653fc42a6e262eeb22700943313f8076f9dc927491b20a59/grpcio-1.81.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c261d74b1a945cf895a9d6eccd1685a8e837531beaab782da4d630a8d12deffb", size = 8407779, upload-time = "2026-06-11T12:46:08.84Z" }, + { url = "https://files.pythonhosted.org/packages/ff/aa/66fe9f39871d766987d869a03ee0842a026f499c7b1e62decb9e78a8088e/grpcio-1.81.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58ad1131c300d3c9b933802b3cc4dc69d380822935ba50b28703156ea826fbf7", size = 7844521, upload-time = "2026-06-11T12:46:12.171Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9e/69bb7194861bcd28fb3193261d4f9c3831b4446993f002cf59068943e7ab/grpcio-1.81.1-cp313-cp313-win32.whl", hash = "sha256:78e29211f26da2fdd0e9c6d2b79f489476140cf7029b6a64808ade7ca4156a42", size = 4182786, upload-time = "2026-06-11T12:46:15.192Z" }, + { url = "https://files.pythonhosted.org/packages/0d/20/3da8bb0d637feccdc3e1e419bb511ce93651ce7d54164f95de22cc0b8b34/grpcio-1.81.1-cp313-cp313-win_amd64.whl", hash = "sha256:edb59506291b647a30884b1d51a599d605f40b20af4a7dc3d33786a47a31de60", size = 4928648, upload-time = "2026-06-11T12:46:17.823Z" }, + { url = "https://files.pythonhosted.org/packages/b6/58/19414622b1bf6981bc9c05a365bd548e71876c89000083b3af489251e9c0/grpcio-1.81.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:506f48f2f9c29b143fca3dad7b0d518c188b6c9648c75a2ae6e2d9f2c13a060b", size = 6055336, upload-time = "2026-06-11T12:46:20.557Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/2ec88adb92b0eba970dd0e0e7dd086341daa3c75eba4f735f9e44bf684b0/grpcio-1.81.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d865db4a6318e1c1bea83292e0ed231090538fc4ca45425b0f0480eb338bbc6e", size = 12056279, upload-time = "2026-06-11T12:46:24.255Z" }, + { url = "https://files.pythonhosted.org/packages/41/36/e8c5f8c6ec71de73733695ebc809e98b178b534ec6d8eaa31a7ebab4ad4c/grpcio-1.81.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2aa72e3ce1770317ef534f63d397b55e130725f5149bd36077c3b539019db27", size = 6608225, upload-time = "2026-06-11T12:46:27.601Z" }, + { url = "https://files.pythonhosted.org/packages/30/22/96fc577a845ab093326d9ab1adb874bd4936c8cf98ac8ed2f3db13a0a2fb/grpcio-1.81.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0490c30c261eded63f3f354979f9dc4502a9fb944cccb60cd9dc85f5a7349854", size = 7306576, upload-time = "2026-06-11T12:46:30.514Z" }, + { url = "https://files.pythonhosted.org/packages/76/7b/61dab5d5969f28d97fb1009cead1df0a5cd987d3315e1b37f18a4449f8bc/grpcio-1.81.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:410482da976329fe5f4067270401b12cf2bd552ff8020f054ecfaddb5475f9d6", size = 6812165, upload-time = "2026-06-11T12:46:33.699Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/6e501929d4f5f96462fd82fd9f0f06e5f9612207582b862868d68757b27d/grpcio-1.81.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3657301562ac3cb8018d30d0d3ebfa39932239f7b5703422057ef14b69949f5", size = 7422962, upload-time = "2026-06-11T12:46:36.511Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7e/f2157589e66daa78ebb3165942d05a08bdea93b9d11c2bc1e172aef89685/grpcio-1.81.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:24c8e57504c8f45b237e40b99262d181071e5099a07053695b75d97bb53053a0", size = 8408176, upload-time = "2026-06-11T12:46:39.803Z" }, + { url = "https://files.pythonhosted.org/packages/da/df/c6717fef716e00d235ffb96123baf6dce76d6004f6233fa767c502861460/grpcio-1.81.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b427c19380991a4eaab2f6144b64b99b412043314c6bf4ab544f97bb31ee4190", size = 7846681, upload-time = "2026-06-11T12:46:43.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/84/3502e9f210a6a5c4438c8aca3f88edd2e04f6a27f3d41b26cf0a0024b096/grpcio-1.81.1-cp314-cp314-win32.whl", hash = "sha256:61233fe8951e5c85dff81c2458b6528624760166946b5b47ea150a589168411f", size = 4264615, upload-time = "2026-06-11T12:46:45.741Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/4af731ff7492c68a96e4c71bfd0f4590acde92b31c6fe4894e6465c10ff6/grpcio-1.81.1-cp314-cp314-win_amd64.whl", hash = "sha256:3768a5ff1b2125e6f552e561b6b2dca0e64982d8949689b4df145cf8b98d7821", size = 5070275, upload-time = "2026-06-11T12:46:48.486Z" }, +] + +[[package]] +name = "grpcio-status" +version = "1.81.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/26/0aa9168c87882381fd810d140c279a2490ed6aee655f0515d6f56c5ca404/grpcio_status-1.81.1.tar.gz", hash = "sha256:9389a03e746017b10f0630c064289201458f3ce01f5d7ef4b0bebc1ef6cf82ad", size = 13923, upload-time = "2026-06-11T12:58:48.636Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/5e/5abfec5f7e89d3b7993d57cfb025ca5f968a2c18656d7fcda2b6919440b9/grpcio_status-1.81.1-py3-none-any.whl", hash = "sha256:08072fa9995f4a95c647fc6f4f85e2411573d00087bcabdf30f260114338f232", size = 14638, upload-time = "2026-06-11T12:58:31.982Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -482,6 +1111,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h5netcdf" +version = "1.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/03/92d6cc02c0055158167255980461155d6e17f1c4143c03f8bcc18d3e3f3a/h5netcdf-1.8.1.tar.gz", hash = "sha256:9b396a4cc346050fc1a4df8523bc1853681ec3544e0449027ae397cb953c7a16", size = 78679, upload-time = "2026-01-23T07:35:31.233Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/8b/88f16936a8e8070a83d36239555227ecd91728f9ef222c5382cda07e0fd6/h5netcdf-1.8.1-py3-none-any.whl", hash = "sha256:a76ed7cfc9b8a8908ea7057c4e57e27307acff1049b7f5ed52db6c2247636879", size = 62915, upload-time = "2026-01-23T07:35:30.195Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -570,6 +1212,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[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 = "joblib" version = "1.5.3" @@ -836,26 +1487,42 @@ polars = [ { name = "polars" }, { name = "pyarrow" }, ] +satellite = [ + { name = "boto3" }, + { name = "gcsfs" }, + { name = "h5netcdf" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "s3fs" }, + { name = "xarray" }, +] [package.metadata] requires-dist = [ + { name = "boto3", marker = "extra == 'satellite'", specifier = ">=1.34,<2.0" }, { name = "cfgrib", marker = "extra == 'nwp'", specifier = ">=0.9.15,<1.0" }, { name = "filelock", specifier = ">=3.12" }, + { name = "gcsfs", marker = "extra == 'satellite'", specifier = ">=2024.0" }, + { name = "h5netcdf", marker = "extra == 'satellite'", specifier = ">=1.3" }, { name = "httpx", specifier = ">=0.27" }, { name = "jsonschema", specifier = ">=4.21" }, { name = "mostlyrightmd", editable = "packages/core" }, { name = "narwhals", marker = "extra == 'polars'", specifier = ">=1.20,<2.0" }, + { name = "numpy", marker = "extra == 'satellite'", specifier = ">=1.24" }, { name = "pandas", marker = "extra == 'nwp'", specifier = ">=2.2,<4.0" }, { name = "pandas", marker = "extra == 'parquet'", specifier = ">=2.2,<4.0" }, { name = "pandas", marker = "extra == 'polars'", specifier = ">=2.2,<4.0" }, + { name = "pandas", marker = "extra == 'satellite'", specifier = ">=2.2,<4.0" }, { name = "polars", marker = "extra == 'polars'", specifier = ">=1.0,<2.0" }, { name = "pyarrow", specifier = ">=17.0,<24.0" }, { name = "pyarrow", marker = "extra == 'polars'", specifier = ">=17.0,<24.0" }, + { name = "s3fs", marker = "extra == 'satellite'", specifier = ">=2024.0" }, { name = "scikit-learn", marker = "extra == 'nwp'", specifier = ">=1.3,<2.0" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "xarray", marker = "extra == 'nwp'", specifier = ">=2024.0" }, + { name = "xarray", marker = "extra == 'satellite'", specifier = ">=2024.0" }, ] -provides-extras = ["parquet", "nwp", "polars"] +provides-extras = ["parquet", "nwp", "polars", "satellite"] [[package]] name = "mostlyrightmd-workspace" @@ -918,6 +1585,123 @@ docs = [ { name = "sphinx-markdown-builder", specifier = ">=0.6,<1" }, ] +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + [[package]] name = "myst-parser" version = "4.0.1" @@ -1032,6 +1816,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, ] +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -1157,6 +1950,144 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "proto-plus" +version = "1.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/56/e647b0c675392d2da368da7b6f158f7368b18542fd6f7d7400a2f39de000/proto_plus-1.28.0.tar.gz", hash = "sha256:38e5696342835b08fc116f30a25665b29531cda9d5d5643e9b81fc312385abd9", size = 57221, upload-time = "2026-05-07T08:04:50.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/20/b122d4626976acb81132036d2ad1bb35a1a8775fceb837ec30964622516a/proto_plus-1.28.0-py3-none-any.whl", hash = "sha256:a630604310899e73c59ec302e5765c058d412b2f090b9c79c8822589f14955b8", size = 50410, upload-time = "2026-05-07T08:03:31.962Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + [[package]] name = "pyarrow" version = "23.0.1" @@ -1207,6 +2138,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807, upload-time = "2026-02-16T10:14:03.892Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -1373,6 +2325,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + [[package]] name = "respx" version = "0.23.1" @@ -1539,6 +2504,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, ] +[[package]] +name = "s3fs" +version = "2026.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiobotocore" }, + { name = "aiohttp" }, + { name = "fsspec" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/00/6677343dc919d6c072bb04d80210afdd22c16838a8d16b3315c122dc728f/s3fs-2026.6.0.tar.gz", hash = "sha256:b28de7082d0a4f72392884bdc497e34a4a1582f675d214c7da0acf6e950a0083", size = 87358, upload-time = "2026-06-16T02:05:48.719Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/0b/f68a968b49876eae0f2a515387093cebb2eb9451380a96741cc20efac0d0/s3fs-2026.6.0-py3-none-any.whl", hash = "sha256:60576e31bb31193c1f643f32b4c6439548720ea6918ac702e21cd757c80b5db8", size = 32573, upload-time = "2026-06-16T02:05:47.608Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/b3/bcdc2f58fa92592db511beda154c2c08d28f21f6c4637f06a42a24b10c21/s3transfer-0.17.1.tar.gz", hash = "sha256:042dd5e3b1b512355e35a23f0223e426b7042e80b97830ea2680ddce327fc45e", size = 159439, upload-time = "2026-05-26T19:45:01.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/dd/904873250a6554fbae40cddbf9198e3cc37a2f1319d5e1a5ce82fe269c17/s3transfer-0.17.1-py3-none-any.whl", hash = "sha256:5b9827d1044159bbb01b86ef8902760ea39281927f5de31de75e1d657177bf4c", size = 88264, upload-time = "2026-05-26T19:45:00.452Z" }, +] + [[package]] name = "scikit-learn" version = "1.8.0" @@ -1930,6 +2921,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, ] +[[package]] +name = "wrapt" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ac/4370bde262c0e633e6c4f0e56d55095710024cf9a5cecc20c59a10de483c/wrapt-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dd57607acc85678925940bd5df0385ff8332083a32fa8d7a43f8767f4997263c", size = 80321, upload-time = "2026-05-22T14:47:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/eb/79/b8ff3a61e71babf58a8cf4c0d63358e8bad383e15bf7f35e62d2f6b6e4a4/wrapt-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ae574d65c9fa8e86f64f6a7c2668f9fcd507b183e0e577619f504b883cb0a6c", size = 81216, upload-time = "2026-05-22T14:47:45.243Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fd/c0cac1f77c9c4f6fe58a920ca632ce379bb8be928720e11e8d73de28a5e9/wrapt-2.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9a04c28c10ba7fd12842b109d2edb0678872a2fe65277ca4ff06a0d61edee245", size = 159208, upload-time = "2026-05-22T14:47:47.176Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4f/744132a7b2fbefa6b81118ec5942eca5fc2e9a129f9055a0c5e46885a549/wrapt-2.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e2f02472a1cbbf3884b365714a810b5947134a95ad6952b554cb8cce9d492b0", size = 160322, upload-time = "2026-05-22T14:47:49.04Z" }, + { url = "https://files.pythonhosted.org/packages/d6/95/b7cd9a22a06cf93e6482904ee6afc956248983553593fd1009296d1b3b31/wrapt-2.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac2745950b2bff80219c15ebf2fa9d8427eba7e249739f97e55c9d169e47e9e1", size = 153243, upload-time = "2026-05-22T14:47:50.386Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4a/eb79423192015f46f0db2872e7e04a3dde8d359b83411e8959e7c9287eaa/wrapt-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67a97e5b6c457f0cd3cfc19ebb2d84463e60c3ece754cc831e4281a3ca29bb18", size = 159231, upload-time = "2026-05-22T14:47:51.753Z" }, + { url = "https://files.pythonhosted.org/packages/ec/dc/435015b58ce33c6fc4104158fa91ddb0e809ab03a5751fb7465d1d461456/wrapt-2.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c803a3d331796255af51ba2c79ed0ac8275865b516c09e61f248d1e7aff31ce9", size = 152351, upload-time = "2026-05-22T14:47:53.214Z" }, + { url = "https://files.pythonhosted.org/packages/77/ac/5d203f98df8fd136b95c5227139aea02d34505e18baf812d0c005df61963/wrapt-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9b984d1eb252145d6302c1dbd5e87fc6d404d45531447c84eadec04bf1fcb027", size = 158347, upload-time = "2026-05-22T14:47:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/a92427dbdc74e54c1674abbed27e61b2cb5e7a94441b8c1270c70671d928/wrapt-2.2.1-cp311-cp311-win32.whl", hash = "sha256:8a983a603a18c8708f024f7f6991b2e66159219abbf894634c5056243c55f3cd", size = 77562, upload-time = "2026-05-22T14:47:56.275Z" }, + { url = "https://files.pythonhosted.org/packages/c8/56/987b9c13b3e1c1a3c6de71284076f996b79caec90e75a87c044a40c23db9/wrapt-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:9c210a6994b21aa9b29e81c8d11560e8fdab54c117e9cff37870d0a27bde1343", size = 80616, upload-time = "2026-05-22T14:47:57.854Z" }, + { url = "https://files.pythonhosted.org/packages/7e/25/d01f560888d99d94a959c85533de349ce68d71ace3f2591d6ea8f632cfed/wrapt-2.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:401229e9d63ca09f9b8891ecf83798d26c11bbb445d11ed9f1836b6d4585b38a", size = 79025, upload-time = "2026-05-22T14:47:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/89/0c/bfae7b9401583b6d05938cd16dedc43857d96da2f8a3d50d78cc515bf6ff/wrapt-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ffad790d9d11d8ecf9f17c4bb671a5b4089e4d8b575c46c5129597f41f836b0", size = 81021, upload-time = "2026-05-22T14:48:00.313Z" }, + { url = "https://files.pythonhosted.org/packages/26/58/80f6a6599f933f4caecc1cb3ee88a04faf81e8b9bddbd6109c688dd63e0f/wrapt-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:628f5220c7a904d5fc78f7075c8d7871433eb6d035c94728a22fdf85f193d2a8", size = 81692, upload-time = "2026-05-22T14:48:01.49Z" }, + { url = "https://files.pythonhosted.org/packages/17/93/fb357cc7847c58a8ae790be718903afa81a28d23e642c843dc4129e8a0b2/wrapt-2.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:61acce4257a9883669703c525447c5b4c392edf0f987ae77ec32668440158f0e", size = 169364, upload-time = "2026-05-22T14:48:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0b/76b601ee309a8bd556af0eecb184394c20b3c49aa9c8e085aa1ffacc2568/wrapt-2.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727ab4244622cd6ad2390f322642090c877d2e83a608d2653a7643ae5368d926", size = 171079, upload-time = "2026-05-22T14:48:04.22Z" }, + { url = "https://files.pythonhosted.org/packages/cd/87/ee3f32d5658e3e26d3e0e457922b47a36dd3bfbdfee7f97bb3e802344a66/wrapt-2.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03df9ebed4c73ab93fa8c07e3d41d818dfca1852b15731a3de59457b27814624", size = 160205, upload-time = "2026-05-22T14:48:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d0/ae2fd64277a67f5d7bffcf2d05eea1e476263fb2a072baf0b0129ab85984/wrapt-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9ff006f420b2ec8296aa56ade43ea7da3e997e85769f0aafc5e0661aacb710", size = 168922, upload-time = "2026-05-22T14:48:07.132Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f3/2d541a060c5bbafb9400bca4917e4d78bfd1f239f404782c86831a8f6b29/wrapt-2.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:844c858fc3bb7eacc0ba8efa904935d16aac6a4470948ad1e7e55c9f5a2a665f", size = 158388, upload-time = "2026-05-22T14:48:08.629Z" }, + { url = "https://files.pythonhosted.org/packages/1d/68/8d92c8800c57e93cb116ae9e9d6cbafc34fade5ee9f9107b6f203fb4dc35/wrapt-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87bacdaf225117a342a20d9c03438d701c02112f6e3f351ce9b7f32354f14797", size = 167682, upload-time = "2026-05-22T14:48:10.042Z" }, + { url = "https://files.pythonhosted.org/packages/30/72/83ea3790ea352439442349388e29ff07b76e0686265f9088bbb505d1608d/wrapt-2.2.1-cp312-cp312-win32.whl", hash = "sha256:2f8c90c8afde51969487be4e1343ae049b268854877d415c2510baf833775052", size = 77857, upload-time = "2026-05-22T14:48:11.782Z" }, + { url = "https://files.pythonhosted.org/packages/ef/cb/99450668dd3502d62a54a1c8aa56e44f34cb8c1261b381cfe2e7926c3b75/wrapt-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ce32763ac31ce94fe9aada947e479b1975012bff166da409b4b9e4e376cf7e5", size = 80825, upload-time = "2026-05-22T14:48:13.046Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/87512881be64e743f9ee4c66f4cbe8e884974bef2a5989af71f999653ac7/wrapt-2.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d1b4d0e0c2119587a31f5c029abd547e0c81d93b89d394566fe1588659eb579", size = 79087, upload-time = "2026-05-22T14:48:14.323Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/a1b08f8f4fac8cbb156fa51cf64ee2c7f7f74f9875ba3cf70b3c58368694/wrapt-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d2beb1c7cab10603aecdc42f8edd6ff013f9a32e4543474e38e6b77ce9975aeb", size = 80831, upload-time = "2026-05-22T14:48:15.598Z" }, + { url = "https://files.pythonhosted.org/packages/54/ce/57890814991446a845e09b3445ce8b694f27eb0577004f2c2a36a9772ed4/wrapt-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0cb7e4dd71f4c32e5e84843cd3c4cd65dda034314004bbe1d7f99af2426ab80", size = 81375, upload-time = "2026-05-22T14:48:17.071Z" }, + { url = "https://files.pythonhosted.org/packages/38/65/08d7a6c76ac4493bdb668205ee9c1de1bd5daca61717c3e9aa49b4c01499/wrapt-2.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95821352042722cd9f1108874579a47989d0a7e12a37d87d2fc4af20fd99ab8a", size = 167417, upload-time = "2026-05-22T14:48:18.303Z" }, + { url = "https://files.pythonhosted.org/packages/62/ce/f1ccbee7a1bfe5cdc6b3da6bab4b45713d628b9294da32a39f563d648140/wrapt-2.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd621552ede77c4c69be7fac44ba911225b0c812b6ba604e5964cf98085b474", size = 166948, upload-time = "2026-05-22T14:48:19.768Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/f85d48d1cd4869aee6704028d257d740a47c1c467b457ce396b4b5b55d07/wrapt-2.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3677c7146ce694874941ba82b57092cc4875445aadf29d72807351023105143", size = 158148, upload-time = "2026-05-22T14:48:21.96Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5c/93939ad11d4a12358ab1aab219a2ef5efa5612e0db6b9fc65af8af1a891b/wrapt-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9a5934eaea872e17936b5f45501eba5ab0bce9a74122e172b663d7c28c459c4a", size = 165905, upload-time = "2026-05-22T14:48:23.373Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/b8c2aa89862ff58605934d7abf4b70e6a5a1c33df96656f49035ccdf1c8a/wrapt-2.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f5b9daf6b629fce418e0cc3dd0436eac045188fa35deadb7a7f3941d5b8203f9", size = 156712, upload-time = "2026-05-22T14:48:24.767Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/bf00a7b02239c12bb02ddcc3c0b971bfcc36e578c5a44f1ccfef5b458545/wrapt-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f53ac9f3ef573326d009ed809beff4efcac6451931c2b8132586da4b9e53ff31", size = 166560, upload-time = "2026-05-22T14:48:26.83Z" }, + { url = "https://files.pythonhosted.org/packages/fe/93/6390ca9c5b787683cef588d04f57c8d41b9a2323b5597a65f18638c90ef2/wrapt-2.2.1-cp313-cp313-win32.whl", hash = "sha256:1ffa9cfd4bdb581539951b14ae661ff20ed0c3599b3e911a131ee0ec5ac11337", size = 77817, upload-time = "2026-05-22T14:48:28.221Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/ce10f0e71c0cfaa1a65faadb8efd4852028b3bb9ba28932b8889df769d38/wrapt-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:368eac1e20fd0bb03dd3cc42bf9887154c3861b60989389ccb5fac032617d215", size = 80736, upload-time = "2026-05-22T14:48:30.139Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4c/89f4a6818fafbbd840330e4fa3873073e1bfc166133a64cac7f8fde7a5e3/wrapt-2.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:c754dafdf5aaf0b401b644a90a30046929a0dd1a536e0ff0ec959a59155d9c7f", size = 79099, upload-time = "2026-05-22T14:48:31.405Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f2/9a8741c46f8c208ac0a45b25ba170bcb4fb72a2781d5fb97dbd7b6be73cb/wrapt-2.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ed928d0fda15fc0adc8d13305c8b3c0f2fba5b0669950c9e6d019d9162a3b3e8", size = 82802, upload-time = "2026-05-22T14:48:33.307Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0d/e9c855716a3705eef1416456bdf062b60620726fdc59428ff670fc3c60dc/wrapt-2.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fafb4e739e43544d12cb4abd1605fd4683b6ca6a9ad682b7fd8f4d21973eafa8", size = 83329, upload-time = "2026-05-22T14:48:34.593Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d6/a88f1c13112b7831adac75cea65d8310e0d696d570c8961844c90a57b865/wrapt-2.2.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:74d6a0c31472fe5d814917266b9f46495d7c61ed890af08b468acea92fb89a8d", size = 202937, upload-time = "2026-05-22T14:48:35.859Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/e29d54aef06a4d898a5b8a25589a0b3769bde454f922fad8f6f89fbfb650/wrapt-2.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab5be648d5a0b86b7438864f8df3c705a65cef35a2fd3e5561e3e203167e0f27", size = 209997, upload-time = "2026-05-22T14:48:38.153Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/e4454263516cf0e12640912fbca9a83654e424f0a6ddb79f5cd7ce14bf33/wrapt-2.2.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d8f204c8e3a8bf9ece17e0a83d137fd807440977f8a5e762d59306795011440", size = 194856, upload-time = "2026-05-22T14:48:39.69Z" }, + { url = "https://files.pythonhosted.org/packages/de/d0/fe0ee202286afdf4a7f77dd29f195703145764d572aec209c5086e57d924/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d047f6498c973874ba08ac3f97c69a2c4b2211c8de6f4c205f75cb1c9522596e", size = 205654, upload-time = "2026-05-22T14:48:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/23/b6/87d860dfc6460c246af70b1fd5c8b76df77571b42a493459423ded94fd7d/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:7a4fdb9326aab4a5a477a1640e5ad786a8495901009d7e7b038371edd23a9d2b", size = 192206, upload-time = "2026-05-22T14:48:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/46/3eea8cde077d985f239a38c0257087b8064fd9ee9b1a99e282d2c86da4ef/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8cc5094b08abeae52da9c73c8a32003623be691a5193df2f4e3eac3d557c394", size = 198428, upload-time = "2026-05-22T14:48:46.319Z" }, + { url = "https://files.pythonhosted.org/packages/18/dc/b927ee9c7fc67adc3a5658f246a0d275425eb840ba36e7b702e70f18bde8/wrapt-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:9907a4402ab6db12b7077a0ea5d7a4d028ecb22c8eee2b53527080d347cd1562", size = 79448, upload-time = "2026-05-22T14:48:47.901Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b3/fd30b473fe498c70e6b9a5f328b8d3fbaf1b8c3c481465f59724bba8eb70/wrapt-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5590d63f5243251641cf543009b4c9314a79d0598fdb8a8e4cfc918494536c53", size = 83021, upload-time = "2026-05-22T14:48:49.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/96c39153a8737a6e9aa85adef254ac4195bea3f2d24efc60472ccc3c9e2e/wrapt-2.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:c318a64b53d97b841d7b5e637517e50a27be64bc695128422953d4b21710954e", size = 80295, upload-time = "2026-05-22T14:48:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a3/11d7f34ebbf3231bc907a3e6d5ee051b14d034c1bc7b65a97d5cc00516df/wrapt-2.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f56a647e4eaf5f0ca40330fb070f566bdf9f7b0db89a1af20d71c28dcd7a0ab", size = 80879, upload-time = "2026-05-22T14:48:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/b74cfd984cef560b900fb1a727af20352d89e1f06bf2e1114dd3f00f5f5a/wrapt-2.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:64b7deeda4b70408e382328d8bbe52a256fe9bc63ae3db86d804608367e5422c", size = 81462, upload-time = "2026-05-22T14:48:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/15/a3/7c8f704b8dc07dfe0a5d01c2edbfd88317aa8e5e3fa7c743eb7a085ae767/wrapt-2.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9cf53ba90717db2e292401de290776c498d4bbfb0d4a559ca2895db8b9dcb5c", size = 167251, upload-time = "2026-05-22T14:48:54.562Z" }, + { url = "https://files.pythonhosted.org/packages/80/85/a34d1888d97247da6c2ff6118c3a721c73ed8cc4dd198c00208bb73b6f80/wrapt-2.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf3638274ab9d9b724c9baa0b4c04e132cd6faefb78b4dd3dd1a02a4bdaad41e", size = 166316, upload-time = "2026-05-22T14:48:56.065Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d7/72ffaeb01eebc704afe3fb99e840480f4bda45f0fa66e3381b6a39251c8f/wrapt-2.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aed9658797d0b45d6c49adcfc6b41f66e6f2d0c6de3ec79e16cf4b1855df240f", size = 157952, upload-time = "2026-05-22T14:48:57.924Z" }, + { url = "https://files.pythonhosted.org/packages/24/5b/36f5d6b024e4edfdd90b140742d11ebcf7836daf5c9daf326c55c24db412/wrapt-2.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d676ee388bc42a04d56dd7deb5605244dac2e35cc2fadbb43c9fa25bbd93508", size = 166130, upload-time = "2026-05-22T14:48:59.384Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/9296d9e97bfdef5483dfcc859d57b095b257144b2bc5300ab521e06f4bc7/wrapt-2.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e395f7bc31851ef9b612050368cb446e9bc14cd7454b025018980349caf25ae5", size = 156604, upload-time = "2026-05-22T14:49:00.921Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/16953929ed6776175720e58fc966e779926d8d71e2c7b2273230590ca71f/wrapt-2.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f1845c2a8cc1180ccccfa45785dd06f562730d19ef75be180334254012b6283", size = 166007, upload-time = "2026-05-22T14:49:02.332Z" }, + { url = "https://files.pythonhosted.org/packages/b9/73/20ee58c0612dae7c31131a7095345812ed2c7b389019e175f68cde34e5b4/wrapt-2.2.1-cp314-cp314-win32.whl", hash = "sha256:436addbc4bb4fc0a88c702577f51195d7d73683a7f3e0e5b253d8404d7847243", size = 78327, upload-time = "2026-05-22T14:49:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/22/b3/ef7c3295d02e0448a71c639a36a057f46d524d057c9486291a7a3039e65c/wrapt-2.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:50972a1d974ea07725a7f6b1cec5f8759008afd030a0024843ebe7d52de47f2b", size = 81144, upload-time = "2026-05-22T14:49:05.093Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dc/7bdf336953f99f4ceb0a584bb8870e42c8f26f93ea10c87834dad62f1668/wrapt-2.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1c9934ea5d92957e3cd0adbc0845539dccfd62710ebe16195a8c66c53954db36", size = 79569, upload-time = "2026-05-22T14:49:06.413Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6d/6dfae80150ff1919c356d1dd528f049bcdfaae29b4d284bc957e022caef4/wrapt-2.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17de18fc12cea55b8a9587314cb830573e37fb33b247a7515696350863714188", size = 82892, upload-time = "2026-05-22T14:49:07.925Z" }, + { url = "https://files.pythonhosted.org/packages/82/7b/4e34766a7d7804ffce9e71befe47e9b3225dc350c49c94493c4ab39fd3a5/wrapt-2.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9dec1aca52dddde7df94818310fa2fe79739c8f385b2014c4cb1035f5508199", size = 83333, upload-time = "2026-05-22T14:49:09.257Z" }, + { url = "https://files.pythonhosted.org/packages/9d/57/0b34db3e8de44ccfece62d7b337abd1631dd810f5adc5f3db571727836b5/wrapt-2.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69f2e9244542cb34dd59c7f073445b9e54ad9f3fce8d93606c368a1b499fc413", size = 202899, upload-time = "2026-05-22T14:49:10.572Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/ac0c459f154b99d92789a6cba7ca727185b83513b986f8ec7fe2aacddcbf/wrapt-2.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d83966dc7f4f45e8b97b5933685ac2e6e67fc0e19246ea314bceb9a8970c956", size = 209986, upload-time = "2026-05-22T14:49:12.229Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/77e37ff33ad018fa81ade52c25fa327b80b56f81d734279a63614fcb4cbc/wrapt-2.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78b0aa6bfb7be8deed0ab23e7aa028cc5210c29bc2d32a04d52b50e517a7307e", size = 194893, upload-time = "2026-05-22T14:49:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9d/7ea651d1ab032fc5fa222fbec91d0f8a1397f6ae04ebb93fa7219aa921d7/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:05d5cb74d1b232ec8cfa130a8f900708699ff2491d97b8f85a4cdc5996294b85", size = 205636, upload-time = "2026-05-22T14:49:15.714Z" }, + { url = "https://files.pythonhosted.org/packages/09/af/8e88031a701275b9085c54e64bc88c0b1cd55c77eadd400691c371cd76c4/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f6518b94edb9150452e9aba08027d4cc293433753ec1fbefb4629a21cbc74181", size = 192267, upload-time = "2026-05-22T14:49:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a8/e657ca876b06710194f243d81c4b0896ade646e244bdbec2d87c8c56a8bd/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed55af48b3eb28f43228ca2306788892bcb629eb2b5c4876e2a3659872c2f17a", size = 198378, upload-time = "2026-05-22T14:49:18.785Z" }, + { url = "https://files.pythonhosted.org/packages/c8/59/822efe4ea722a3961331bfa35b7d90937790d2c20f0616de1997ccc3aebd/wrapt-2.2.1-cp314-cp314t-win32.whl", hash = "sha256:2e08688ab16525897da6589d56d0aebaf417bbe91c2d8e3b96203b1efa596e85", size = 80226, upload-time = "2026-05-22T14:49:20.264Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/2a7dc5f6abb2fca0b6e1610e120419f603650aceb4f1d3ac4cae0354e162/wrapt-2.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd0135d34387f5fd087d9be368ea77ea89cf2451dc1cd1c622d35021bcb3ab50", size = 83835, upload-time = "2026-05-22T14:49:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c0/782b86e28d1ceebeb74cccea12d2cd3d2ba0bd68e3dec20b1bc5873f6127/wrapt-2.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00", size = 80722, upload-time = "2026-05-22T14:49:23.59Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, +] + [[package]] name = "xarray" version = "2026.4.0" @@ -1943,3 +3009,102 @@ sdist = { url = "https://files.pythonhosted.org/packages/4b/a6/6fe936a798a3a38a7 wheels = [ { url = "https://files.pythonhosted.org/packages/dc/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-py3-none-any.whl", hash = "sha256:d43751d9fb4a90f9249c30431684f00c41bc874f1edccd862631a40cbc0edf08", size = 1414326, upload-time = "2026-04-13T19:45:34.659Z" }, ] + +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/c5/1ce244152ff2839645e7cae92f90e7bafcb2c52bea7ff586ac714f14f5df/yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1", size = 128971, upload-time = "2026-05-19T21:28:20.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/5a/00f36967203ed89cb3acd2c8ed526cc3fed9418eb70ce128160a911c8499/yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c", size = 91507, upload-time = "2026-05-19T21:28:22.556Z" }, + { url = "https://files.pythonhosted.org/packages/31/d0/1fb0c1cd27288f39f6974da4318c32768d72c9890984541fdf1e2e32a51d/yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d", size = 91343, upload-time = "2026-05-19T21:28:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/03/ce/d4a646508bed2f8dec6435b40166fe9308dd191262033d3f307b2bbcaecd/yarl-1.24.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae", size = 105704, upload-time = "2026-05-19T21:28:25.872Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/b3278e82d8bc41485bcf6d856cd0433262593de615b1d3dc43bd3f5bead4/yarl-1.24.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a", size = 97281, upload-time = "2026-05-19T21:28:27.352Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/4cee6e7c92e487bebe7afc797da0aa54a248ab4e776a68fe369ec29665a5/yarl-1.24.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e", size = 114020, upload-time = "2026-05-19T21:28:29.458Z" }, + { url = "https://files.pythonhosted.org/packages/5c/82/111076571545a7d4f9cca3fbd5c6f40615af58642be09f12328f48022468/yarl-1.24.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50", size = 111450, upload-time = "2026-05-19T21:28:31.262Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ec/08f671f69a444d704aeecebf92af659b67b97a869942411d0a578b08c334/yarl-1.24.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003", size = 106384, upload-time = "2026-05-19T21:28:32.856Z" }, + { url = "https://files.pythonhosted.org/packages/e5/86/ce41e7a7a199340b2330d52b60f25c4074b6636dd0e60b1a80d31a9db042/yarl-1.24.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f", size = 106153, upload-time = "2026-05-19T21:28:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5d/31be8a729531ab3e55ac3e7e5c800be8c89ea98947f418b2f6ea259fb6ee/yarl-1.24.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f", size = 105322, upload-time = "2026-05-19T21:28:36.642Z" }, + { url = "https://files.pythonhosted.org/packages/47/9b/b57afb22b386ae87ac9940f09878b98d8c333f89113e6fc96fcf4ca9eb64/yarl-1.24.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294", size = 99057, upload-time = "2026-05-19T21:28:38.386Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4f/06348c27c8389256c313e8a57d796808fc0264c915dd5e7cfd3c0e314dc7/yarl-1.24.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2", size = 113502, upload-time = "2026-05-19T21:28:40.091Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1c/284f307b298e4a17b7943b07d9d7ecc4151537f8d137ba51f3bb6c31ca20/yarl-1.24.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c", size = 105253, upload-time = "2026-05-19T21:28:41.987Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bf/0de123bec8619e45c80cbded9085f61b5b4a9eddb8abe6d25d28ee1ec866/yarl-1.24.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b", size = 111345, upload-time = "2026-05-19T21:28:43.93Z" }, + { url = "https://files.pythonhosted.org/packages/90/af/0248eb065e51129d2a9b2436cd1b5c772c19a6b04e5b6a186955671e3319/yarl-1.24.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5", size = 106558, upload-time = "2026-05-19T21:28:45.806Z" }, + { url = "https://files.pythonhosted.org/packages/21/3c/f960d7a65ef97d8ba9b424fb5128796a4bc710fc6df2ddbbd7dfdc3bbd20/yarl-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45", size = 92808, upload-time = "2026-05-19T21:28:48.465Z" }, + { url = "https://files.pythonhosted.org/packages/03/1a/49fb03750e4de4d2284cd5b885a383133c34eef45bd59631b2bb8b7e81e8/yarl-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122", size = 87610, upload-time = "2026-05-19T21:28:50.07Z" }, + { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, + { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, + { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, + { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, + { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, + { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, + { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, + { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, + { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, + { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, + { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, + { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, + { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, + { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, + { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +]