diff --git a/.github/workflows/docs_pages.yaml b/.github/workflows/docs_pages.yaml index 90a84d3..dd52ba3 100644 --- a/.github/workflows/docs_pages.yaml +++ b/.github/workflows/docs_pages.yaml @@ -1,5 +1,8 @@ name: Docs2Pages -on: [ push, pull_request, workflow_dispatch ] +on: + push: + branches: [main] + workflow_dispatch: permissions: {} @@ -24,14 +27,15 @@ jobs: - name: Install dependencies run: uv sync --all-extras - - name: Build MkDocs site - run: uv run mkdocs build --strict + - name: Build Sphinx + Furo site + # `-W` promotes warnings to errors so docstring/signature drift fails CI. + run: uv run sphinx-build -W --keep-going -b html docs/source docs/build/sphinx - name: Upload Pages artifact if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: actions/upload-pages-artifact@v3 with: - path: ./docs/build/html + path: ./docs/build/sphinx deploy: needs: build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6e2ee91..f242ba5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,27 +1,90 @@ -name: publish.yml +name: Publish (PyPI) + +# Publishes any tag starting with 'v' (e.g. v1.0, v0.5.1a0) to PyPI via OIDC +# trusted publishing. The publish job is gated on the full pytest matrix AND +# the strict Sphinx build passing on the tagged commit — we don't ship a +# release that fails CI or has broken docs. on: push: tags: - # publishes any tag starting with 'v' as in 'v1.0' - v* +permissions: {} + jobs: - run: + # Re-run the full test matrix on the tagged commit. Yes, this is similar + # to tests.yaml — but a release deserves an explicit, self-contained gate + # rather than a `workflow_run` dependency on another workflow's run (which + # would only work if tests.yaml was on the default branch at the time of + # the tag, a footgun). + tests: + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: true + matrix: + python-version: [ "3.12", "3.13", "3.14" ] + name: pytest (Python ${{ matrix.python-version }}) + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Install Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --all-extras --python ${{ matrix.python-version }} + + - name: Run pytest + run: | + uv run --python ${{ matrix.python-version }} pytest -v -m "not network" + + # Strict Sphinx build — same gate `tests.yaml` runs on every dev push. + # A release deserves the same docstring/signature drift check. + docs: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Install Python 3.13 + run: uv python install 3.13 + + - name: Install dependencies + run: uv sync --all-extras + + - name: Build Sphinx + Furo site (strict) + run: uv run sphinx-build -W --keep-going -b html docs/source docs/build/sphinx + + publish: + needs: [tests, docs] runs-on: ubuntu-latest environment: name: publish permissions: - id-token: write + id-token: write # OIDC trusted publishing contents: read steps: - name: Checkout uses: actions/checkout@v5 + - name: Install uv uses: astral-sh/setup-uv@v6 + - name: Install Python 3.13 run: uv python install 3.13 + - name: Build run: uv build - # Need to add a test that verifies the builds - - name: Publish + + - name: Publish to PyPI run: uv publish diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..a50e299 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,168 @@ +name: Tests +on: + push: + branches: + - '**' + # Tag pushes (v*) are handled by publish.yml, which runs the same matrix + # before publishing — skip here to avoid running the suite twice. + tags-ignore: + - '**' + # `docs/**` is intentionally NOT ignored: docs-only commits still need + # to validate via the strict Sphinx build (the `docs` job below). + paths-ignore: + - 'README.md' + - 'CLAUDE.md' + - '.github/workflows/docs_pages.yaml' + pull_request: + paths-ignore: + - 'README.md' + - 'CLAUDE.md' + - '.github/workflows/docs_pages.yaml' + workflow_dispatch: + +permissions: {} + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + pytest: + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + python-version: [ "3.12", "3.13", "3.14" ] + name: pytest (Python ${{ matrix.python-version }}) + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Install Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --all-extras --python ${{ matrix.python-version }} + + # Network-dependent tests need a live OSH server (e.g. localhost:8282). + # They're tagged `@pytest.mark.network` and skipped here. The plan is + # to shim those with mocks; once a test no longer needs a real server, + # drop the marker and it will run in CI automatically. + - name: Run pytest with coverage + run: | + uv run --python ${{ matrix.python-version }} pytest -v \ + -m "not network" \ + --cov --cov-report=term --cov-report=xml + + # Keep coverage.xml around so a later badge/Codecov upload step can use it. + - name: Upload coverage report artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ matrix.python-version }} + path: coverage.xml + if-no-files-found: warn + retention-days: 7 + + # Strict Sphinx build acts as a docstring/signature drift gate. Runs in + # parallel with pytest; publish-test depends on both. Same `-W` flag the + # Pages deploy uses (docs_pages.yaml), so any failure here would also + # break the production deploy on main. The built site is uploaded as a + # workflow artifact so dev-branch docs changes can be previewed without + # deploying to GitHub Pages (which only happens from main). + docs: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Install Python 3.13 + run: uv python install 3.13 + + - name: Install dependencies + run: uv sync --all-extras + + - name: Build Sphinx + Furo site (strict) + run: uv run sphinx-build -W --keep-going -b html docs/source docs/build/sphinx + + - name: Upload built docs as artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: docs-html + path: docs/build/sphinx + if-no-files-found: warn + retention-days: 14 + + # Publish a `.devN` pre-release wheel to TestPyPI on every push to dev, + # gated on BOTH the full pytest matrix and the strict docs build passing. + # Lives in this workflow (rather than a separate `workflow_run`-triggered + # file) so that the gate is a plain `needs:` dependency — `workflow_run` + # only fires from workflows that exist on the default branch, which is a + # maintenance footgun. + # + # One-time setup required at https://test.pypi.org/manage/account/publishing/ + # Owner: Botts-Innovative-Research + # Repo: OSHConnect-Python + # Workflow: tests.yaml + # Environment: publish-test + # And in this repo's Settings -> Environments, create env `publish-test`. + publish-test: + needs: [pytest, docs] + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + runs-on: ubuntu-latest + environment: + name: publish-test + url: https://test.pypi.org/project/oshconnect/ + permissions: + id-token: write # OIDC trusted publishing + contents: read + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Install Python 3.13 + run: uv python install 3.13 + + # Append `.dev` to the version in pyproject.toml so each + # dev push gets a fresh PEP 440-compliant pre-release (e.g. + # 0.5.1a0 -> 0.5.1a0.dev42). The change lives only on the runner. + - name: Auto-bump version with .devN suffix + run: | + python - <<'PY' + import os, pathlib, re + run = os.environ['GITHUB_RUN_NUMBER'] + p = pathlib.Path('pyproject.toml') + src = p.read_text() + new = re.sub( + r'^(version\s*=\s*")([^"]+)(")', + lambda m: f'{m.group(1)}{m.group(2)}.dev{run}{m.group(3)}', + src, count=1, flags=re.M, + ) + if new == src: + raise SystemExit('No `version = "..."` line found in pyproject.toml') + p.write_text(new) + for line in new.splitlines(): + if line.startswith('version'): + print(f'Bumped {line}') + break + PY + + - name: Build + run: uv build + + - name: Publish to TestPyPI + run: uv publish --publish-url https://test.pypi.org/legacy/ diff --git a/.gitignore b/.gitignore index 5779839..011106b 100644 --- a/.gitignore +++ b/.gitignore @@ -164,3 +164,9 @@ cython_debug/ .python-version poetry.lock + +# Demo-script artifacts (examples/axis_video_frame.py writes here) +examples/_out/ + +# Runtime selection state written by examples/axis_video_mqtt_stream.py +examples/axis_video_config.json diff --git a/README.md b/README.md index 0a24c55..1fc3273 100644 --- a/README.md +++ b/README.md @@ -9,50 +9,148 @@ Links: * [Architecture Doc](https://docs.google.com/document/d/1pIaeQw0ocU6ApNgqTVRZuSwjJAbhCcmweMq6RiVYEic/edit?usp=sharing) * [UML Diagram](https://drive.google.com/file/d/1FVrnYiuAR8ykqfOUa1NuoMyZ1abXzMPw/view?usp=drive_link) -## Generating the Docs +## Pre-releases + +Every push to the `dev` branch publishes a `.devN` pre-release wheel to +[TestPyPI](https://test.pypi.org/project/oshconnect/) once the test suite +passes. To install the latest: + +```bash +pip install --index-url https://test.pypi.org/simple/ \ + --extra-index-url https://pypi.org/simple/ \ + oshconnect --pre +``` -The documentation is built with [MkDocs](https://www.mkdocs.org/) using the -Material theme, [mkdocstrings](https://mkdocstrings.github.io/) for -auto-generated API reference from the source, and -[mermaid](https://mermaid.js.org/) for architecture diagrams. Markdown sources -live under `docs/markdown/`. +The `--extra-index-url` is needed so transitive deps (pydantic, paho-mqtt, +…) still resolve from real PyPI. Tagged releases (`v*`) continue to publish +to real PyPI via `.github/workflows/publish.yml`. -Install dev dependencies (including MkDocs and plugins): +## Running Tests ```bash -uv sync +uv sync # install dev deps (incl. pytest, pytest-cov) +uv run pytest # full suite (skips network-marked tests if you add `-m "not network"`) +uv run pytest tests/test_swe_components.py -v # one file, verbose +uv run pytest -k name_token # one keyword ``` -Build the HTML docs: +Tests that need a live OSH server (e.g. `localhost:8282` running +FakeWeatherDriver) are tagged `@pytest.mark.network`. CI skips them; locally +you can include or exclude them: ```bash -uv run mkdocs build +uv run pytest -m "not network" # what CI runs +uv run pytest -m network # only the live-server tests ``` -The output will be in `docs/build/html/`. Open `docs/build/html/index.html` in -a browser to view locally. +## Test Coverage -For a live-reloading preview while editing: +Coverage is opt-in via [`pytest-cov`](https://pytest-cov.readthedocs.io/). The +default `pytest` run is fast; add `--cov` when you want a report. ```bash -uv run mkdocs serve +uv run pytest --cov # terminal summary + missing lines +uv run pytest --cov --cov-report=html # HTML report at htmlcov/index.html +uv run pytest --cov --cov-report=xml # coverage.xml (CI / Codecov-ready) ``` -Then visit http://127.0.0.1:8000. +Configuration lives in `pyproject.toml` under `[tool.coverage.*]` — branch +coverage is on, source is scoped to `src/oshconnect`, and obvious dead lines +(`if TYPE_CHECKING:`, `raise NotImplementedError`, etc.) are excluded. + +CI (`.github/workflows/tests.yaml`) runs the suite with `--cov` on every push +across Python 3.12 / 3.13 / 3.14 and uploads `coverage.xml` as a workflow +artifact (downloadable from the run page). + +## Documentation Coverage -To match what CI publishes (warnings become errors — useful when you've -touched docstrings): +[`interrogate`](https://interrogate.readthedocs.io/) reports what fraction of +public modules / classes / functions / methods carry a docstring (presence +only, it doesn't check style). It's purely informational right now; there's +no CI gate. Configuration lives in `pyproject.toml` under `[tool.interrogate]` +(`__init__`, dunder, private, and property/setter members are skipped). ```bash -uv run mkdocs build --strict +uv run interrogate src/oshconnect # one-line summary +uv run interrogate -v src/oshconnect # per-file table +uv run interrogate -vv src/oshconnect # per-symbol (shows which symbols are missing) ``` -CI builds the site on every push and deploys `main` to GitHub Pages via -`.github/workflows/docs_pages.yaml`. +Once we agree on a baseline, raise `[tool.interrogate].fail-under` from `0` so +new code without docstrings starts failing locally and in CI. + +## OGC Format Serialization + +Format-explicit conversion methods on the wrapper classes (`System`, +`Datastream`, `ControlStream`) and the underlying pydantic resource models. +Use these to round-trip CS API server JSON in **SML+JSON**, **OM+JSON**, and +**SWE+JSON** without having to remember the `model_dump(by_alias=True, …)` +incantation, and to construct OSHConnect wrappers from raw server payloads. + +```python +from oshconnect import Node, System, Datastream + +node = Node(protocol="http", address="localhost", port=8282) + +# Build a System from an SML+JSON server response +sys_dict = {"type": "PhysicalSystem", "uniqueId": "urn:test:1", "label": "Sensor"} +sys = System.from_csapi_dict(sys_dict, node) # auto-detects SML vs GeoJSON +sys.to_smljson_dict() # -> dict ready to POST + +# Build a Datastream from a CS API listing entry +ds = Datastream.from_csapi_dict(ds_json, node) +ds.to_csapi_dict() # the resource body +ds.schema_to_swejson_dict() # the SWE+JSON schema doc +ds.observation_to_omjson_dict({"temperature": 22.5}) # one OM+JSON observation + +# Single observations / commands +from oshconnect.resource_datamodels import ObservationResource +obs = ObservationResource.from_omjson_dict(om_json_payload) +obs.to_swejson_dict() # flat SWE+JSON record +``` + +The two older static factories `System.from_system_resource` and +`Datastream.from_resource` are deprecated in favor of `from_csapi_dict` and +emit `DeprecationWarning` on use. They'll be removed in a future major +version. + +## Generating the Docs + +The documentation is built with [Sphinx](https://www.sphinx-doc.org/) using +the [Furo](https://pradyunsg.me/furo/) theme, +[autodoc](https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html) +for auto-generated API reference from docstrings, +[MyST](https://myst-parser.readthedocs.io/) so that Markdown source files +work alongside reST, and [sphinxcontrib-mermaid](https://github.com/mgaitan/sphinxcontrib-mermaid) +for the architecture diagrams. Sources live under `docs/source/`. + +Install dev dependencies (including Sphinx, Furo, and the plugins): + +```bash +uv sync --all-extras +``` -The legacy Sphinx setup under `docs/source/` is kept temporarily for -reference and builds to a separate output directory: +Build the HTML docs: ```bash uv run sphinx-build -b html docs/source docs/build/sphinx -``` \ No newline at end of file +``` + +Open `docs/build/sphinx/index.html` in a browser to view locally. + +For a live-reloading preview while editing, use +[sphinx-autobuild](https://github.com/sphinx-doc/sphinx-autobuild): + +```bash +uv run --with sphinx-autobuild sphinx-autobuild docs/source docs/build/sphinx +``` + +To match what CI publishes (warnings become errors — useful after touching +docstrings or signatures): + +```bash +uv run sphinx-build -W --keep-going -b html docs/source docs/build/sphinx +``` + +CI builds the site on every push and deploys `main` to GitHub Pages via +`.github/workflows/docs_pages.yaml`. \ No newline at end of file diff --git a/docs/markdown/api.md b/docs/markdown/api.md deleted file mode 100644 index 8a0f205..0000000 --- a/docs/markdown/api.md +++ /dev/null @@ -1,90 +0,0 @@ -# API Reference - -All public symbols are re-exported from the top-level package and can be -imported directly: - -```python -from oshconnect import OSHConnect, Node, Datastream, TimePeriod, ObservationFormat -``` - -Lower-level CS API utilities are available from the `oshconnect.csapi4py` -sub-package: - -```python -from oshconnect.csapi4py import APIResourceTypes, MQTTCommClient, ConnectedSystemsRequestBuilder -``` - ---- - -## Core Application - -::: oshconnect.oshconnectapi - ---- - -## Streamable Resources - -The primary objects for interacting with systems, datastreams, and control -streams on an OSH node. Includes `Node`, `System`, `Datastream`, -`ControlStream`, and supporting enums. - -::: oshconnect.streamableresource - ---- - -## Resource Data Models - -Pydantic models that represent CS API resources returned from or sent to an -OSH server. - -::: oshconnect.resource_datamodels - ---- - -## SWE Schema Components - -Builder classes for constructing datastream and command schemas using SWE -Common data types. - -::: oshconnect.swe_components - -::: oshconnect.schema_datamodels - ---- - -## Event System - -Pub/sub event bus for in-process notifications. Implement `IEventListener` -to receive events. - -::: oshconnect.eventbus - -::: oshconnect.events.core - -::: oshconnect.events.builder - ---- - -## Time Management - -::: oshconnect.timemanagement - ---- - -## CS API Integration (`csapi4py`) - -### Constants and Enums - -::: oshconnect.csapi4py.constants - -### Request Builder - -::: oshconnect.csapi4py.con_sys_api - -### API Helper - -::: oshconnect.csapi4py.default_api_helpers - -### MQTT Client - -::: oshconnect.csapi4py.mqtt \ No newline at end of file diff --git a/docs/markdown/index.md b/docs/markdown/index.md deleted file mode 100644 index 09bbf67..0000000 --- a/docs/markdown/index.md +++ /dev/null @@ -1,24 +0,0 @@ -# OSHConnect-Python - -OSHConnect-Python is the Python member of the OSHConnect family of application -libraries. It provides a simple, straightforward way to interact with -OpenSensorHub (or any other OGC API – Connected Systems server). - -It supports Parts 1, 2, and 3 (Pub/Sub) of the OGC Connected Systems API, -including: - -- System, Datastream, and ControlStream discovery and management -- Real-time MQTT streaming using CS API Part 3 `:data` topic conventions -- Resource event topic subscriptions (CloudEvents lifecycle notifications) -- Batch retrieval and archival stream playback -- Configuration persistence (JSON save/load) -- SWE Common schema builders for defining datastream and command schemas - -All major classes and utilities are importable directly from `oshconnect`. -Lower-level CS API utilities are available from `oshconnect.csapi4py`. - -## Where to next - -- [Architecture](architecture.md) — object hierarchy, data flow, and key abstractions -- [Tutorial](tutorial.md) — common workflows for connecting, discovering, streaming, and inserting resources -- [API Reference](api.md) — auto-generated reference for every public symbol \ No newline at end of file diff --git a/docs/markdown/tutorial.md b/docs/markdown/tutorial.md deleted file mode 100644 index 6a4afa7..0000000 --- a/docs/markdown/tutorial.md +++ /dev/null @@ -1,208 +0,0 @@ -# Tutorial - -OSHConnect-Python is a library for interacting with OpenSensorHub through -OGC API – Connected Systems. This tutorial walks through the most common -workflows. - -## Installation - -Install with `uv` (recommended): - -```bash -uv add git+https://github.com/Botts-Innovative-Research/OSHConnect-Python.git -``` - -Or with `pip`: - -```bash -pip install git+https://github.com/Botts-Innovative-Research/OSHConnect-Python.git -``` - -All public classes and utilities can be imported directly from `oshconnect`: - -```python -from oshconnect import OSHConnect, Node, System, Datastream, ControlStream -from oshconnect import TimePeriod, TimeInstant, TemporalModes -from oshconnect import DataRecordSchema, QuantitySchema, TimeSchema, TextSchema -from oshconnect import ObservationFormat, DefaultEventTypes -``` - -## Creating an OSHConnect instance - -The main entry point is the `OSHConnect` class: - -```python -from oshconnect import OSHConnect, TemporalModes - -app = OSHConnect(name='MyApp') -``` - -## Adding a Node - -A `Node` represents a connection to a single OSH server. The `OSHConnect` -instance can manage multiple nodes simultaneously. - -```python -from oshconnect import OSHConnect, Node - -app = OSHConnect(name='MyApp') -node = Node(protocol='http', address='localhost', port=8585, - username='test', password='test') -app.add_node(node) -``` - -To connect a node with MQTT support for streaming: - -```python -node = Node(protocol='http', address='localhost', port=8585, - username='test', password='test', - enable_mqtt=True, mqtt_port=1883) -app.add_node(node) -``` - -## Discovery - -Discover all systems available on all registered nodes: - -```python -app.discover_systems() -``` - -Discover all datastreams across all discovered systems: - -```python -app.discover_datastreams() -``` - -## Streaming observations (MQTT) - -Once a node is configured with MQTT and datastreams are discovered, start -receiving observations by initializing and starting each datastream: - -```python -from oshconnect import StreamableModes - -for ds in app.get_datastreams(): - ds.set_connection_mode(StreamableModes.PULL) - ds.initialize() - ds.start() -``` - -Incoming messages are appended to each datastream's inbound deque: - -```python -import time - -time.sleep(2) # allow messages to arrive -for ds in app.get_datastreams(): - while ds.get_inbound_deque(): - msg = ds.get_inbound_deque().popleft() - print(msg) -``` - -## Resource event subscriptions - -Subscribe to resource lifecycle events (create / update / delete) using -`subscribe_events()`. These arrive as CloudEvents v1.0 JSON payloads: - -```python -def on_event(client, userdata, msg): - print(f"Event on {msg.topic}: {msg.payload}") - -for ds in app.get_datastreams(): - topic = ds.subscribe_events(callback=on_event) - print(f"Subscribed to event topic: {topic}") -``` - -## Inserting a new System - -```python -from oshconnect import OSHConnect, Node - -app = OSHConnect(name='MyApp') -node = Node(protocol='http', address='localhost', port=8585, - username='admin', password='admin') -app.add_node(node) - -new_system = app.create_and_insert_system( - system_opts={ - 'name': 'Test System', - 'description': 'A test system', - 'uid': 'urn:system:test:001', - }, - target_node=node -) -``` - -## Inserting a new Datastream - -Build a schema using SWE Common component classes, then attach it to a system: - -```python -from oshconnect import DataRecordSchema, TimeSchema, QuantitySchema, TextSchema -from oshconnect.api_utils import URI, UCUMCode - -datarecord = DataRecordSchema( - label='Example Record', - description='Example datastream record', - definition='http://example.org/records/example', - fields=[] -) - -# TimeSchema must be the first field for OSH -datarecord.fields.append( - TimeSchema(label='Timestamp', - definition='http://www.opengis.net/def/property/OGC/0/SamplingTime', - name='timestamp', - uom=URI(href='http://www.opengis.net/def/uom/ISO-8601/0/Gregorian')) -) -datarecord.fields.append( - QuantitySchema(name='distance', label='Distance', - definition='http://example.org/Distance', - uom=UCUMCode(code='m', label='meters')) -) -datarecord.fields.append( - TextSchema(name='label', label='Label', - definition='http://example.org/Label') -) - -datastream = new_system.add_insert_datastream(datarecord) -``` - -!!! note - A `TimeSchema` must be the first field in the `DataRecordSchema` when - targeting OpenSensorHub. - -## Inserting an Observation - -Once a datastream is registered, send observation data using -`insert_observation_dict()`: - -```python -from oshconnect import TimeInstant - -datastream.insert_observation_dict({ - 'resultTime': TimeInstant.now_as_time_instant().get_iso_time(), - 'phenomenonTime': TimeInstant.now_as_time_instant().get_iso_time(), - 'result': { - 'timestamp': TimeInstant.now_as_time_instant().epoch_time, - 'distance': 1.0, - 'label': 'example observation', - } -}) -``` - -!!! note - The keys in `result` correspond to the `name` fields of each schema - component. `resultTime` and `phenomenonTime` are required by - OpenSensorHub. - -## Saving and loading configuration - -The OSHConnect state (nodes, systems, datastreams) can be persisted to a JSON -file: - -```python -app.save_config() # saves to a default file -app = OSHConnect.load_config('my_config.json') -``` \ No newline at end of file diff --git a/docs/source/api.rst b/docs/source/api.rst index e9a101b..09ae411 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -64,10 +64,15 @@ Event System ------------ Pub/sub event bus for in-process notifications. Implement ``IEventListener`` to receive events. +The names below are re-exported from ``oshconnect.events.core``, +``oshconnect.events.handler``, etc.; ``:no-index:`` keeps Sphinx from +reporting them as duplicate object descriptions. + .. automodule:: oshconnect.eventbus :members: :undoc-members: :show-inheritance: + :no-index: --- diff --git a/docs/source/architecture/class_hierarchy.md b/docs/source/architecture/class_hierarchy.md new file mode 100644 index 0000000..f34a2ec --- /dev/null +++ b/docs/source/architecture/class_hierarchy.md @@ -0,0 +1,284 @@ +# Class hierarchy + +OSHConnect's type system has three roughly-orthogonal trees: the +**user-facing wrappers** (`Node`, `System`, `Datastream`, `ControlStream`), +the **CS API resource models** that those wrappers serialize to/from on the +wire, and the **SWE Common schema components** that describe the shape of +observations and commands. + +## Wrapper hierarchy + +The wrapper classes are in `streamableresource.py`. `StreamableResource[T]` +is an abstract, generic base — `T` is the underlying pydantic resource +model the wrapper holds (`SystemResource`, `DatastreamResource`, or +`ControlStreamResource`). The base manages the MQTT subscribe/publish +plumbing and inbound/outbound deques common to all three concretions. + +```mermaid +classDiagram + direction TB + class Node { + +protocol: str + +address: str + +port: int + +discover_systems() + +add_system() + +get_api_helper() APIHelper + +to_storage_dict() dict + } + class StreamableResource~T~ { + <> + +get_streamable_id() UUID + +initialize() + +start() + +stop() + +subscribe_mqtt(topic) + +publish(payload, topic) + +to_storage_dict() dict + } + class System { + +name: str + +urn: str + +datastreams: list~Datastream~ + +control_channels: list~ControlStream~ + +discover_datastreams() + +add_insert_datastream() + +to_smljson_dict() dict + +to_geojson_dict() dict + } + class Datastream { + +get_id() str + +create_observation() + +observation_to_omjson_dict() + +observation_to_swejson_dict() + } + class ControlStream { + +publish_command() + +publish_status() + +command_to_json_dict() + +command_to_swejson_dict() + } + + Node "1" o-- "*" System : owns + System "1" o-- "*" Datastream : owns + System "1" o-- "*" ControlStream : owns + + StreamableResource <|-- System + StreamableResource <|-- Datastream + StreamableResource <|-- ControlStream +``` + +`Node` is intentionally *not* a `StreamableResource` — it's a connection +holder, not a streamable. + +## CS API resource models + +Pydantic models in `resource_datamodels.py`. Each is what `model_dump(by_alias=True)` +produces a CS API JSON body from, and what `model_validate(data, by_alias=True)` +parses a server response into. The wrapper classes above hold one of these +as `_underlying_resource`. + +```mermaid +classDiagram + direction TB + class BaseModel { + <> + +model_dump() + +model_validate() + } + class BaseResource { + +id: str + +name: str + +description: str + +type: str + +links: List~Link~ + } + class SystemResource { + +feature_type: str // "PhysicalSystem" or "Feature" + +system_id: str + +uid: str + +label: str + +to_smljson_dict() + +to_geojson_dict() + +from_csapi_dict() classmethod + } + class DatastreamResource { + +ds_id: str + +name: str + +valid_time: TimePeriod + +record_schema: DatastreamRecordSchema + +to_csapi_dict() + +from_csapi_dict() classmethod + } + class ControlStreamResource { + +cs_id: str + +input_name: str + +command_schema: CommandSchema + +to_csapi_dict() + +from_csapi_dict() classmethod + } + class ObservationResource { + +result_time: TimeInstant + +phenomenon_time: TimeInstant + +result: dict + +to_omjson_dict() + +to_swejson_dict() + } + + BaseModel <|-- BaseResource + BaseModel <|-- SystemResource + BaseModel <|-- DatastreamResource + BaseModel <|-- ControlStreamResource + BaseModel <|-- ObservationResource +``` + +The `record_schema` / `command_schema` slots are typed +`SerializeAsAny[DatastreamRecordSchema]` / +`SerializeAsAny[CommandSchema]` so they preserve discriminated-union +polymorphism on dump — see the schema document tree below. + +## Schema documents + +`schema_datamodels.py` defines the polymorphic schema wrappers that live +inside `DatastreamResource.record_schema` and +`ControlStreamResource.command_schema`. The discriminator is the format +field (`obs_format` or `command_format`). + +```mermaid +classDiagram + direction TB + class DatastreamRecordSchema { + <> + +obs_format: str + } + class SWEDatastreamRecordSchema { + +obs_format = "application/swe+json" + +encoding: Encoding + +record_schema: AnyComponent + } + class OMJSONDatastreamRecordSchema { + +obs_format = "application/om+json" + +result_schema: AnyComponent + +parameters_schema: AnyComponent + } + + class CommandSchema { + <> + +command_format: str + } + class SWEJSONCommandSchema { + +command_format = "application/swe+json" + +encoding: Encoding + +record_schema: AnyComponent + } + class JSONCommandSchema { + +command_format = "application/json" + +params_schema: AnyComponent + +result_schema: AnyComponent + +feasibility_schema: AnyComponent + } + + DatastreamRecordSchema <|-- SWEDatastreamRecordSchema + DatastreamRecordSchema <|-- OMJSONDatastreamRecordSchema + CommandSchema <|-- SWEJSONCommandSchema + CommandSchema <|-- JSONCommandSchema +``` + +Each variant has a `to_*_dict()` / `from_*_dict()` convenience method +matching its format — see [Serialization](serialization.md). + +## SWE Common component union + +`swe_components.py` defines the SWE Common Data Model component types as a +discriminated union (`AnyComponent = Annotated[Union[...], Field(discriminator="type")]`). +The `type` literal on each subclass routes pydantic to the right concrete +class on parse. + +```mermaid +classDiagram + direction TB + class AnyComponentSchema { + +type: str + +id: str + +name: str + +label: str + +description: str + } + class AnySimpleComponentSchema { + +reference_frame: str + +axis_id: str + +nil_values: list + } + class AnyScalarComponentSchema + class DataRecordSchema { + +type = "DataRecord" + +fields: list~AnyComponent~ + } + class VectorSchema { + +type = "Vector" + +reference_frame: str + +coordinates: list~Count|Quantity|Time~ + } + class DataArraySchema { + +type = "DataArray" + +element_type: AnyComponent + } + class DataChoiceSchema { + +type = "DataChoice" + +items: list~AnyComponent~ + } + class GeometrySchema { + +type = "Geometry" + +srs: str + } + class QuantitySchema { + +type = "Quantity" + +uom: UCUMCode|URI + } + class BooleanSchema { + +type = "Boolean" + } + class CountSchema { + +type = "Count" + } + class TimeSchema { + +type = "Time" + +uom: UCUMCode|URI + } + class TextSchema { + +type = "Text" + } + class CategorySchema { + +type = "Category" + } + + AnyComponentSchema <|-- DataRecordSchema + AnyComponentSchema <|-- VectorSchema + AnyComponentSchema <|-- DataArraySchema + AnyComponentSchema <|-- DataChoiceSchema + AnyComponentSchema <|-- GeometrySchema + AnyComponentSchema <|-- AnySimpleComponentSchema + AnySimpleComponentSchema <|-- AnyScalarComponentSchema + AnyScalarComponentSchema <|-- BooleanSchema + AnyScalarComponentSchema <|-- CountSchema + AnyScalarComponentSchema <|-- QuantitySchema + AnyScalarComponentSchema <|-- TimeSchema + AnyScalarComponentSchema <|-- CategorySchema + AnyScalarComponentSchema <|-- TextSchema +``` + +(Range variants — `CountRangeSchema`, `QuantityRangeSchema`, `TimeRangeSchema`, +`CategoryRangeSchema` — extend `AnySimpleComponentSchema` directly and are +omitted from the diagram for brevity.) + +## SoftNamedProperty + +The `name` field is *not* a property of any data component itself per SWE +Common 3 — it lives on the `SoftNamedProperty` wrapper that binds a child +into a parent. OSHConnect enforces this via `@model_validator(mode="after")` +on the seven binding contexts: `DataRecord.fields`, `DataChoice.items`, +`Vector.coordinates`, `DataArray.elementType`, `Matrix.elementType`, and +the root recordSchema/resultSchema/parametersSchema of datastream and +control-stream wrappers. + +See `tests/test_swe_components.py` for the full validation surface. diff --git a/docs/source/architecture/construction.md b/docs/source/architecture/construction.md new file mode 100644 index 0000000..0d0ce25 --- /dev/null +++ b/docs/source/architecture/construction.md @@ -0,0 +1,282 @@ +# Constructing wrappers + +A `System`, `Datastream`, or `ControlStream` wrapper is a thin shell +around a pydantic resource model (`SystemResource`, +`DatastreamResource`, `ControlStreamResource`) plus a `Node` for HTTP / +MQTT / streaming context. Wrappers handle node-attached operations +(insertion, MQTT pub/sub, schema fetches over HTTP, storage-layer +round-trip); **format conversion lives entirely on the resource +models**. + +That separation drives the construction story: build the resource via +the resource model's parsers, then bind it to a parent node via the +wrapper's constructor / factory. + +## At-a-glance matrix + +```{list-table} +:header-rows: 1 +:widths: 26 26 26 22 + +* - Input + - System + - Datastream + - ControlStream +* - Individual fields
(new local resource) + - `System(name=, label=, urn=, parent_node=, …)` + - via `System.add_insert_datastream(DataRecordSchema)` — also POSTs server-side + - via `System.add_and_insert_control_stream(DataRecordSchema, input_name=…)` — also POSTs server-side +* - Parsed `*Resource` model + - `System.from_resource(sys_res, node)`
(`from_system_resource` is deprecated) + - `Datastream(parent_node=node, datastream_resource=ds_res)`
(`from_resource` is deprecated) + - `ControlStream(node=node, controlstream_resource=cs_res)` +* - Storage dict
(round-trip from `to_storage_dict`) + - `System.from_storage_dict(data, node)` + - `Datastream.from_storage_dict(data, node)` + - `ControlStream.from_storage_dict(data, node)` +``` + +For raw CS API JSON, parse it through the resource model first: + +```{list-table} +:header-rows: 1 +:widths: 30 70 + +* - Raw input + - Resource-model parser +* - SML+JSON dict + - `SystemResource.from_smljson_dict(data)` +* - GeoJSON dict + - `SystemResource.from_geojson_dict(data)` +* - Any system shape (auto-detect) + - `SystemResource.from_csapi_dict(data)` +* - CS API datastream dict + - `DatastreamResource.from_csapi_dict(data)` +* - CS API control-stream dict + - `ControlStreamResource.from_csapi_dict(data)` +* - Single OM+JSON observation + - `ObservationResource.from_omjson_dict(data)` +* - Single SWE+JSON observation + - `ObservationResource.from_swejson_dict(data, schema=…, result_time=…)` +* - SWE+JSON schema document + - `SWEDatastreamRecordSchema.from_swejson_dict(data)` +* - OM+JSON schema document + - `OMJSONDatastreamRecordSchema.from_omjson_dict(data)` +* - OSH logical schema (`obsFormat=logical`) + - `LogicalDatastreamRecordSchema.from_logical_dict(data)` +``` + +## When to use which + +### "I'm building a brand-new system from scratch" + +Use the `System` constructor directly, then `insert_self()` (or let +`OSHConnect.create_and_insert_system(...)` do both). The wrapper +generates a `SystemResource` internally via `to_system_resource()`. + +```python +from oshconnect import Node, System + +node = Node(protocol='http', address='localhost', port=8282, + username='admin', password='admin') +sys = System( + name='WeatherStation', + label='Weather Station #1', + urn='urn:osh:sensor:weather:001', + parent_node=node, +) +sys.insert_self() # POST /systems +print(sys.get_streamable_id()) # local UUID +print(sys._resource_id) # server-assigned ID from Location header +``` + +### "I just got a JSON response back from a CS API server" + +Two steps: parse the JSON via the matching resource-model factory, then +hand the resource to the wrapper. + +```python +import requests +from oshconnect import Node, System, Datastream +from oshconnect.resource_datamodels import SystemResource, DatastreamResource + +node = Node(protocol='http', address='localhost', port=8282, + username='admin', password='admin') + +# System: SML+JSON or GeoJSON, auto-detected by the resource model +resp = requests.get('http://localhost:8282/sensorhub/api/systems/abc') +sys = System.from_resource(SystemResource.from_csapi_dict(resp.json()), node) + +# Datastream: single shape (application/json) +resp = requests.get('http://localhost:8282/sensorhub/api/datastreams/def') +ds = Datastream( + parent_node=node, + datastream_resource=DatastreamResource.from_csapi_dict(resp.json()), +) +``` + +If you already know the format and want to skip the auto-detect, swap +in `from_smljson_dict(...)` / `from_geojson_dict(...)` on +`SystemResource`. The wrapper layer doesn't care — it just receives a +pydantic model. + +### "I have a `*Resource` already in memory" + +```python +from oshconnect import Datastream, ControlStream, System + +# System — `from_resource` binds a parsed SystemResource to a node +sys = System.from_resource(sys_resource, node) + +# Datastream — constructor takes the parsed resource directly +ds = Datastream(parent_node=node, datastream_resource=ds_resource) + +# ControlStream — same pattern +cs = ControlStream(node=node, controlstream_resource=cs_resource) +``` + +`System.from_resource` handles both wire shapes that round-trip through +`SystemResource` — the GeoJSON form (with name/uid under `properties`) +and the SML form (label/uid directly on the resource). The deprecated +`System.from_system_resource` emits a `DeprecationWarning` and is a +shim for `from_resource`. + +### "I want to dump the wrapper back to JSON" + +Reach down to the resource model. Format conversion isn't on the +wrapper: + +```python +sys.to_system_resource().to_smljson_dict() # SML+JSON +sys.to_system_resource().to_geojson_dict() # GeoJSON +ds._underlying_resource.to_csapi_dict() # datastream resource body +cs._underlying_resource.to_csapi_dict() # control-stream resource body + +# Schema documents: through the schema model +ds._underlying_resource.record_schema.to_swejson_dict() +ds._underlying_resource.record_schema.to_omjson_dict() +cs._underlying_resource.command_schema.to_json_dict() +``` + +### "I want the schema for an existing datastream from the server" + +For datastreams that came back from `System.discover_datastreams()`, +the SWE+JSON schema is **already cached** on +`_underlying_resource.record_schema`. The CS API listing endpoint +omits the inner schema, so discovery makes a second HTTP call per +datastream (`GET /datastreams/{id}/schema?obsFormat=application/swe+json`) +and assigns the result onto the underlying resource. Reading +`ds._underlying_resource.record_schema` post-discovery returns the +populated `SWEDatastreamRecordSchema` without another network call. +A schema fetch that fails for a single datastream is downgraded to a +warning so it doesn't poison the rest of the discovery; that +datastream's `record_schema` stays `None`. + +For datastreams built locally (no discovery), or when you need the +OM+JSON or logical variant, hit the schema endpoint directly through +the parent `Node`'s `APIHelper` and parse with the matching schema +model: + +```python +from oshconnect.csapi4py.constants import APIResourceTypes +from oshconnect.schema_datamodels import ( + SWEDatastreamRecordSchema, + OMJSONDatastreamRecordSchema, + LogicalDatastreamRecordSchema, +) + +api = node.get_api_helper() +ds_id = ds._underlying_resource.ds_id + +# SWE+JSON (CS API spec) +sw_resp = api.get_resource(APIResourceTypes.DATASTREAM, ds_id, + APIResourceTypes.SCHEMA, + params={'obsFormat': 'application/swe+json'}) +sw = SWEDatastreamRecordSchema.from_swejson_dict(sw_resp.json()) + +# OM+JSON (CS API spec) +om_resp = api.get_resource(APIResourceTypes.DATASTREAM, ds_id, + APIResourceTypes.SCHEMA, + params={'obsFormat': 'application/om+json'}) +om = OMJSONDatastreamRecordSchema.from_omjson_dict(om_resp.json()) + +# OSH-specific JSON Schema flavor +lg_resp = api.get_resource(APIResourceTypes.DATASTREAM, ds_id, + APIResourceTypes.SCHEMA, + params={'obsFormat': 'logical'}) +lg = LogicalDatastreamRecordSchema.from_logical_dict(lg_resp.json()) +``` + +`api.get_resource(...)` returns a `requests.Response`; the +`from_*_dict` classmethods on each schema model parse it into the +typed pydantic class. None of these calls mutate the datastream's +`_underlying_resource.record_schema` — only `discover_datastreams` +populates that, and only with the SWE+JSON variant. If you want to +cache an OM+JSON or logical fetch, assign it yourself. + +The **logical schema** is OSH-specific (not in the OGC CS API spec): +a JSON Schema document with OGC extension keywords +(`x-ogc-definition`, `x-ogc-refFrame`, `x-ogc-unit`, `x-ogc-axis`) +carrying the SWE Common metadata. + +### "I'm restoring state from local storage" + +`from_storage_dict()` rebuilds wrappers from the dicts produced by +`to_storage_dict()`. Used by `OSHConnect.load_config()` and the SQLite +datastore (`oshconnect.datastores.sqlite_store`); not what you want for +parsing CS API server responses (those have a different shape — use +the resource models for those). + +```python +import json +from oshconnect import Node, System + +with open('my_app_config.json') as f: + cfg = json.load(f) + +node = Node.from_storage_dict(cfg['nodes'][0]) +for sys_dict in cfg['systems']: + sys = System.from_storage_dict(sys_dict, node) + node.add_system(sys) +``` + +## What about new datastreams/controlstreams without going through System? + +The `Datastream(...)` and `ControlStream(...)` constructors require an +already-built resource object — there's no "build from individual fields" +path because building one of these correctly requires defining the +schema (`SWEDatastreamRecordSchema` or `JSONCommandSchema`) and threading +it through a `DatastreamResource` / `ControlStreamResource`. The +high-level entry points handle that for you: + +- `System.add_insert_datastream(DataRecordSchema)` — wraps a schema as + `SWEDatastreamRecordSchema` (with `JSONEncoding`), builds the + `DatastreamResource`, POSTs to the server, and returns the `Datastream`. +- `System.add_and_insert_control_stream(DataRecordSchema, input_name=…)` — + symmetric for ControlStreams via `JSONCommandSchema`. + +If you really want to build from scratch without inserting, copy what +those two methods do (see `streamableresource.py` for the recipe). + +## Why no `to_*_dict` / `from_*_dict` on the wrappers? + +Because format conversion is the resource model's job. Keeping it there +gives one canonical entry point per format, so there's no question of +"is `System.to_smljson_dict()` the same as `system_resource.to_smljson_dict()`?" +— there's only the latter. The wrapper's job is to bind a resource to a +parent node and run the operations that need that node (HTTP, MQTT, +storage). Two layers, two responsibilities. + +The deprecated `System.from_system_resource` and `Datastream.from_resource` +shims remain for one release as compatibility — both delegate to the new +canonical paths. + +## See also + +- [Class hierarchy](class_hierarchy.md) — the type relationships among + wrappers, resource models, and schema documents. +- [Insertion sequence](insertion.md) — the POST flow that follows + construction when you want to push a new resource server-side. +- [Serialization](serialization.md) — the format-explicit `to_*_dict` + / `from_*_dict` methods on the resource models, including the OGC + format coverage matrix. diff --git a/docs/source/architecture/events.md b/docs/source/architecture/events.md new file mode 100644 index 0000000..33518c1 --- /dev/null +++ b/docs/source/architecture/events.md @@ -0,0 +1,178 @@ +# Event system + +OSHConnect has two pub/sub layers and they're easy to confuse: + +- **MQTT pub/sub** — across the network. Datastreams subscribe to + `:data/` topics on the OSH server's MQTT broker (e.g. + `…/observations:data/swe-binary`); ControlStreams publish commands to + the matching `…/commands:data/` topic and receive status on + `…/status:data/json`. The hyphen-token format subtopic per CS API + Part 3 §"Resource Data Messages Content Negotiation" — see the + tutorial's *MQTT topic conventions* section for the full mapping. + Implemented via `paho-mqtt` in `csapi4py/mqtt.py`. +- **In-process EventHandler** — within the Python process. A singleton + pub/sub bus that fans out `Event` objects to in-app listeners (e.g. a + visualization widget that wants to know whenever a new observation + arrives). Implemented in `events/`. + +This page is about the second one. The two are connected: when a Datastream +receives an MQTT message, its `_emit_inbound_event(msg)` hook builds an +`Event` and publishes it to the in-process bus. + +## Class diagram + +```mermaid +classDiagram + direction TB + class EventHandler { + <> + +listeners: list~IEventListener~ + +event_queue: deque~Event~ + +register_listener(listener) + +unregister_listener(listener) + +subscribe(callback, types, topics) + +publish(event) + } + class IEventListener { + <> + +topics: list~str~ + +types: list~DefaultEventTypes~ + +handle_events(event)* + } + class CallbackListener { + +callback: Callable + +handle_events(event) + } + class Event { + +timestamp: datetime + +type: DefaultEventTypes + +topic: str + +data: Any + +producer: Any + } + class EventBuilder { + -_event: Event + +with_type(t) + +with_topic(s) + +with_data(d) + +with_producer(p) + +build() Event + } + class DefaultEventTypes { + <> + NEW_OBSERVATION + NEW_COMMAND + NEW_COMMAND_STATUS + ADD_NODE / REMOVE_NODE + ADD_SYSTEM / REMOVE_SYSTEM + ADD_DATASTREAM / REMOVE_DATASTREAM + ADD_CONTROLSTREAM / REMOVE_CONTROLSTREAM + } + + EventHandler "1" o-- "*" IEventListener : holds + IEventListener <|-- CallbackListener + EventBuilder ..> Event : builds + EventHandler ..> Event : dispatches + Event --> DefaultEventTypes : typed by +``` + +`AtomicEventTypes` (CRUD verbs: CREATE, POST, GET, MODIFY, UPDATE, REMOVE, +DELETE) is a separate enum used for finer-grained sub-classification of +resource operations; it's not directly attached to `Event` but is available +for callers building their own event taxonomies. + +## Subscribe → publish → dispatch + +The handler is reentrancy-safe: if a listener calls `publish()` while the +handler is already inside another `publish()` (the `publish_lock` is held), +the new event is queued and drained after the current dispatch finishes. +Same for `register_listener` / `unregister_listener` mid-dispatch — they're +deferred to `to_add` / `to_remove` lists and flushed by `commit_changes()`. + +```mermaid +sequenceDiagram + autonumber + actor User + participant H as EventHandler + participant L as CallbackListener + participant DS as Datastream + participant MQTT as MQTT Broker + + Note over User,L: 1. Subscribe + User->>H: subscribe(my_callback, types=[NEW_OBSERVATION]) + H->>L: CallbackListener(callback=my_callback, types=[NEW_OBSERVATION]) + H->>H: register_listener(L) + + Note over MQTT,L: 2. MQTT message arrives → in-process event + MQTT-->>DS: paho-mqtt callback (msg) + DS->>DS: _mqtt_sub_callback(msg) + DS->>DS: _inbound_deque.append(msg.payload) + DS->>DS: _emit_inbound_event(msg) + DS->>DS: EventBuilder().with_type(NEW_OBSERVATION).with_topic(msg.topic)
.with_data(msg.payload).with_producer(self).build() + DS->>H: publish(evt) + H->>H: publish_lock = True + loop for each listener + H->>H: _matches(listener, evt)? + alt type & topic match + H->>L: handle_events(evt) + L->>User: my_callback(evt) + end + end + H->>H: publish_lock = False
commit_changes() // drain queued events / listeners +``` + +## Subscribing in user code + +Two styles, both call into the same `EventHandler` singleton: + +**Functional (no subclassing):** + +```python +from oshconnect import EventHandler, DefaultEventTypes + +handler = EventHandler() + +def on_observation(event): + print(f"{event.topic}: {event.data!r}") + +listener = handler.subscribe( + on_observation, + types=[DefaultEventTypes.NEW_OBSERVATION], +) +# later, to stop receiving: +handler.unregister_listener(listener) +``` + +**Subclass:** + +```python +from oshconnect import EventHandler, IEventListener, DefaultEventTypes + +class MyListener(IEventListener): + def handle_events(self, event): + ... + +EventHandler().register_listener( + MyListener(types=[DefaultEventTypes.ADD_SYSTEM]) +) +``` + +Empty `types` or `topics` lists mean "match all" — the handler filters +before dispatching, so you don't need to filter inside your callback. + +## What emits which events + +| Source | Event type | Emitted from | +|---|---|---| +| Inbound observation on a Datastream's MQTT data topic | `NEW_OBSERVATION` | `Datastream._emit_inbound_event` | +| Inbound command on a ControlStream's command topic | `NEW_COMMAND` | `ControlStream._emit_inbound_event` | +| Inbound status on a ControlStream's status topic | `NEW_COMMAND_STATUS` | `ControlStream._emit_inbound_event` | +| Resource lifecycle events (`ADD_NODE`, `ADD_SYSTEM`, etc.) | matching `DefaultEventTypes` | currently emitted by the wrapper classes during construction / discovery (see `eventbus.py` re-exports for the full list) | + +## See also + +- `eventbus.py` re-exports `EventHandler`, `Event`, `EventBuilder`, + `IEventListener`, `CallbackListener`, `DefaultEventTypes`, and + `AtomicEventTypes` for convenient import from `oshconnect`. +- [Class hierarchy](class_hierarchy.md) for how the listener interface + fits into the broader type system. diff --git a/docs/markdown/architecture.md b/docs/source/architecture/index.md similarity index 90% rename from docs/markdown/architecture.md rename to docs/source/architecture/index.md index 98549f5..e07e036 100644 --- a/docs/markdown/architecture.md +++ b/docs/source/architecture/index.md @@ -2,6 +2,20 @@ OSHConnect is structured around a small number of long-lived objects that mirror the resource hierarchy of the OGC API – Connected Systems specification. +Start here for the 30-second tour; the subpages go into depth on the type +system, the POST/insertion path, the in-process event bus, and how the OGC +format methods slot together. + +```{toctree} +:maxdepth: 1 +:caption: Deep dives + +class_hierarchy +construction +insertion +events +serialization +``` ## Object hierarchy @@ -82,6 +96,9 @@ sequenceDiagram Note over App,DS: To insert: resource.insert_self() →
APIHelper.create_resource() → POST →
server returns Location header with new ID ``` +For the inverse direction (creating resources server-side), see +[Insertion sequence](insertion.md). + ## Dependencies - **pydantic** — all resource and schema models. Bumping the minimum requires @@ -90,4 +107,4 @@ sequenceDiagram - **shapely** — geometry handling for spatial resources. - **paho-mqtt** — MQTT streaming for CS API Part 3. - **websockets** / **aiohttp** — WebSocket and async HTTP streaming. -- **requests** — synchronous HTTP for discovery and resource creation. \ No newline at end of file +- **requests** — synchronous HTTP for discovery and resource creation. diff --git a/docs/source/architecture/insertion.md b/docs/source/architecture/insertion.md new file mode 100644 index 0000000..794b342 --- /dev/null +++ b/docs/source/architecture/insertion.md @@ -0,0 +1,120 @@ +# Insertion sequence + +Counterpart to the discovery flow on the [Architecture overview](index.md): +this page traces what happens when *you* push a new resource to the +server. All paths land in `APIHelper.create_resource(...)` which performs +the HTTP POST and returns the response — what differs is how the body is +constructed and where the new resource ID gets captured from the response +`Location` header. + +## Inserting a System + +`OSHConnect.create_and_insert_system(...)` is the typical entry point. +Internally it builds a `System` wrapper, asks it to render its +`SystemResource`, and posts the SML+JSON body. + +```mermaid +sequenceDiagram + autonumber + actor User + participant App as OSHConnect + participant N as Node + participant Sys as System + participant SR as SystemResource + participant H as APIHelper + participant Server as OSH Server + + User->>App: create_and_insert_system(opts, target_node) + App->>Sys: System(name, label, urn, parent_node=N) + Sys->>SR: to_system_resource() + Note over SR: feature_type = "PhysicalSystem"
uid, label set from System + Sys->>App: returns System instance + App->>Sys: insert_self() + Sys->>SR: model_dump_json(by_alias=True, exclude_none=True) + Sys->>H: create_resource(SYSTEM, body, headers={"Content-Type": "application/sml+json"}) + H->>Server: POST /systems + Server-->>H: 201 Created
Location: /systems/{new_id} + H-->>Sys: response + Sys->>Sys: _resource_id = location.split('/')[-1] + Sys-->>App: System with server-side ID populated + App-->>User: System +``` + +The same pattern applies if you skip the `OSHConnect` convenience and +build a `System` directly: just call `system.insert_self()` and the wrapper +handles dump → POST → ID-capture itself. + +## Inserting a Datastream + +Similar shape, but the body is wrapped inside a +`SWEDatastreamRecordSchema` first (carrying the `obs_format` discriminator +and the `JSONEncoding` block), and the POST targets the parent system's +`/datastreams` subresource. + +```mermaid +sequenceDiagram + autonumber + actor User + participant Sys as System + participant Sch as SWEDatastreamRecordSchema + participant DR as DatastreamResource + participant DS as Datastream + participant H as APIHelper + participant Server as OSH Server + + User->>Sys: add_insert_datastream(datarecord_schema) + Sys->>Sch: SWEDatastreamRecordSchema(record_schema=datarecord_schema,
obs_format="application/swe+json", encoding=JSONEncoding()) + Sys->>DR: DatastreamResource(name, output_name, record_schema=Sch, valid_time) + Sys->>H: create_resource(DATASTREAM, body, parent_res_id=system_id) + H->>Server: POST /systems/{system_id}/datastreams + Server-->>H: 201 Created
Location: /datastreams/{new_id} + H-->>Sys: response + Sys->>DR: ds_id = location.split('/')[-1] + Sys->>DS: Datastream(parent_node, datastream_resource=DR) + DS->>DS: set_parent_resource_id(system_id) + Sys->>Sys: datastreams.append(DS) + Sys-->>User: Datastream with server-side ID populated +``` + +## Inserting a ControlStream + +`System.add_and_insert_control_stream(...)` mirrors the datastream flow +above. Differences: + +- The schema wrapper is `JSONCommandSchema` (or `SWEJSONCommandSchema`) + instead of `SWEDatastreamRecordSchema`. The example uses the JSON form + with `params_schema`. +- The endpoint is `/systems/{system_id}/controlstreams` instead of + `/datastreams`. +- The wrapper class produced is `ControlStream`, with a `_status_topic` + computed alongside the regular command topic during construction. + +Otherwise the dump → POST → `Location` header → ID-capture chain is +identical. + +## What `APIHelper.create_resource` does + +`APIHelper.create_resource(resource_type, body, parent_res_id=None, +req_headers=None)` is the single choke point for all POST flows. It: + +1. Calls `endpoints.construct_url(resource_type, parent_res_id=...)` to + build the right URL (e.g. `/sensorhub/api/systems/{id}/datastreams`). +2. Builds a `ConnectedSystemAPIRequest` carrying the URL, body, + `req_headers`, and the auth tuple from `self.get_helper_auth()` + (which returns `(username, password)` when the node was constructed + with credentials, else `None`). +3. Calls `.make_request()`, which dispatches into + `csapi4py.request_wrappers.post_request` → + `requests.post(url, data|json, headers, auth)`. +4. Returns the raw `requests.Response` — the caller is responsible for + inspecting `res.ok` and parsing `res.headers['Location']`. + +The wrapper classes own the `Location` parsing (you can see it on each +`insert_*` method in `streamableresource.py`). That keeps `APIHelper` +generic across all six CS API resource types. + +## See also + +- [Class hierarchy](class_hierarchy.md) for the wrapper / resource model relationship. +- [Serialization](serialization.md) for the `to_*_dict` methods used to + build the POST body. diff --git a/docs/source/architecture/serialization.md b/docs/source/architecture/serialization.md new file mode 100644 index 0000000..12ba81b --- /dev/null +++ b/docs/source/architecture/serialization.md @@ -0,0 +1,151 @@ +# OGC format serialization + +Format-explicit conversion methods live on the **resource models** in +`resource_datamodels.py` and the **schema models** in +`schema_datamodels.py`. The wrapper classes (`System`, `Datastream`, +`ControlStream`) intentionally don't have format-conversion methods — +they bind a resource to a parent node and handle node-attached +operations (HTTP, MQTT, storage). To go between wire JSON and a +wrapper, route through the resource model. + +## The three-layer matrix + +```{list-table} +:header-rows: 1 +:widths: 14 28 28 30 + +* - Resource type + - Resource representation
(the `/{type}/{id}` body) + - Schema document
(the `…/schema` body) + - Single record
(one obs / one command) +* - **System** (`SystemResource`) + - SML+JSON: `to_smljson_dict` / `from_smljson_dict`
GeoJSON: `to_geojson_dict` / `from_geojson_dict`
Auto-detect parse: `from_csapi_dict` + - n/a + - n/a +* - **Datastream** (`DatastreamResource`) + - `to_csapi_dict` / `from_csapi_dict`
(application/json — single shape) + - SWE+JSON: `SWEDatastreamRecordSchema.to_swejson_dict` / `from_swejson_dict`
OM+JSON: `OMJSONDatastreamRecordSchema.to_omjson_dict` / `from_omjson_dict`
OSH logical: `LogicalDatastreamRecordSchema.to_logical_dict` / `from_logical_dict` + - OM+JSON: `ObservationResource.to_omjson_dict` / `from_omjson_dict`
SWE+JSON: `ObservationResource.to_swejson_dict` / `from_swejson_dict` +* - **ControlStream** (`ControlStreamResource`) + - `to_csapi_dict` / `from_csapi_dict` + - SWE+JSON: `SWEJSONCommandSchema.to_swejson_dict` / `from_swejson_dict`
JSON: `JSONCommandSchema.to_json_dict` / `from_json_dict` + - JSON: `CommandJSON.to_csapi_dict` / `from_csapi_dict`
SWE+JSON: pass `payload` through directly (flat dict) +``` + +Each `to_*_dict()` returns a dict (camelCase keys per CS API alias); +each has a matching JSON-string variant (`to_*_json()`) where it makes +sense, and an inverse `from_*_dict()` `@classmethod` that returns the +parsed pydantic model. Round-trips are byte-stable for fixture-style +input. + +## Why this isn't on the wrapper classes + +Wrappers and resources have different jobs: + +- **Resource models** know about pydantic alias rules, the SWE Common + validation rules (SoftNamedProperty, NameToken pattern), and the + multiple wire formats each model can serialize to. Format + conversion belongs here. +- **Wrapper classes** (`System`, `Datastream`, `ControlStream`) bind a + resource to a parent `Node`, manage MQTT subscriptions / WebSocket + streams, run HTTP operations (insert, fetch schema), and hand state + to the storage layer. They don't duplicate the resource model's + format methods. + +Going from raw JSON to a wrapper is therefore explicitly two steps: + +```python +from oshconnect import Datastream +from oshconnect.resource_datamodels import DatastreamResource + +# 1. Resource model: parse the JSON into a typed pydantic instance. +ds_resource = DatastreamResource.from_csapi_dict(server_response_json) + +# 2. Wrapper: bind that resource to a parent node. +ds = Datastream(parent_node=node, datastream_resource=ds_resource) +``` + +Going the other way is also one extra hop but the same pattern: + +```python +ds._underlying_resource.to_csapi_dict() # the resource body +ds._underlying_resource.record_schema.to_swejson_dict() # the schema doc (if SWE) +``` + +## Round-trip example: a single OM+JSON observation + +```mermaid +sequenceDiagram + autonumber + actor User + participant Server as OSH Server + participant OOI as ObservationOMJSONInline + participant Obs as ObservationResource + + Note over Server,User: Inbound: server -> ObservationResource + Server-->>User: MQTT message
{"datastream@id": "ds-1",
"resultTime": "2026-...",
"result": {"temperature": 22.5}} + User->>OOI: ObservationOMJSONInline.model_validate(payload) + OOI-->>User: validated wrapper (alias-aware) + User->>Obs: ObservationResource.from_omjson_dict(payload) + Obs-->>User: ObservationResource (with TimeInstant, etc.) + + Note over Server,User: Outbound: ObservationResource -> server + User->>Obs: obs.to_omjson_dict(datastream_id="ds-1") + Obs->>OOI: ObservationOMJSONInline(...) + OOI->>OOI: model_dump(by_alias=True, exclude_none=True, mode='json') + OOI-->>User: {"datastream@id": "ds-1", "resultTime": "...", "result": {...}} +``` + +The SWE+JSON observation path is similar but flatter: SWE+JSON encodes +a single observation as a flat JSON object whose keys are the schema's +`fields[*].name` values. `ObservationResource.to_swejson_dict()` +returns `obs.result` directly; `from_swejson_dict()` wraps a flat dict +as `result` on a fresh `ObservationResource`. + +## System: SML+JSON vs GeoJSON + +The same `SystemResource` model serves both shapes — only the +`feature_type` discriminator field differs: + +- `feature_type = "PhysicalSystem"` → SML+JSON shape (top-level `uniqueId`, + `label`, optional SensorML metadata fields). +- `feature_type = "Feature"` → GeoJSON shape (top-level `properties` + dict carrying `name`/`uid`, optional `geometry`). + +`SystemResource.from_csapi_dict()` inspects the incoming dict's `type` +field and dispatches to `from_smljson_dict()` or `from_geojson_dict()` +accordingly. To go from a `SystemResource` to a `System` wrapper, use +`System.from_resource(sys_res, parent_node)`. + +## Logical schema (OSH-specific) + +A third schema model, `LogicalDatastreamRecordSchema`, covers OSH's +`?obsFormat=logical` response shape — a JSON Schema document with OGC +extension keywords (`x-ogc-definition`, `x-ogc-refFrame`, `x-ogc-unit`, +`x-ogc-axis`) carrying SWE Common metadata. Distinct from the SWE+JSON +and OM+JSON envelopes (no `obsFormat` field, no `recordSchema` +wrapper). To retrieve it, use the per-`Node` `APIHelper`: +`api.get_resource(APIResourceTypes.DATASTREAM, ds_id, APIResourceTypes.SCHEMA, params={'obsFormat': 'logical'})`, +then parse the response with +`LogicalDatastreamRecordSchema.from_logical_dict(...)`. + +## Deprecated factories + +Two older static factories remain for backwards compatibility: + +- `System.from_system_resource(sys_res, parent_node)` — emits + `DeprecationWarning`. Use `System.from_resource(sys_res, parent_node)`. +- `Datastream.from_resource(ds_res, parent_node)` — emits + `DeprecationWarning`. Use the constructor directly: + `Datastream(parent_node=node, datastream_resource=ds_res)`. + +Both will be removed in a future major version. + +## See also + +- [Class hierarchy](class_hierarchy.md) — the resource and schema model + trees these methods live on. +- [Construction](construction.md) — how to build a wrapper once you've + parsed a resource model from JSON, plus the schema-fetch methods. +- [Insertion sequence](insertion.md) — how the dump output flows into + `APIHelper.create_resource()` for POSTs. diff --git a/docs/source/conf.py b/docs/source/conf.py index b018781..9552659 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,15 +1,10 @@ # Configuration file for the Sphinx documentation builder. -# -# For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html - -# -- Project information ----------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information - import os import sys import traceback +# Make the package importable for autodoc. sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../src'))) @@ -21,31 +16,96 @@ def setup(app): app.connect('autodoc-process-docstring', process_exception) +# -- Project information ----------------------------------------------------- + project = 'OSHConnect-Python' -copyright = '2025, Botts Innovative Research, Inc.' +copyright = '2025-2026, Botts Innovative Research, Inc.' author = 'Ian Patterson' -release = '0.4' +release = '0.5.1' # -- General configuration --------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration - extensions = [ + 'sphinx.ext.autodoc', # API ref from docstrings + 'sphinx.ext.autosummary', # autodoc summaries + 'sphinx.ext.napoleon', # Google / Sphinx docstring styles + 'sphinx.ext.viewcode', # link to source on each symbol + 'sphinx.ext.intersphinx', # cross-link to Python stdlib / pydantic 'sphinx.ext.doctest', 'sphinx.ext.duration', - 'sphinx.ext.autodoc', - 'sphinx.ext.autosummary', + 'myst_parser', # Markdown support (so we can keep .md sources) + 'sphinxcontrib.mermaid', # mermaid diagrams from architecture.md + 'sphinx_copybutton', # copy-to-clipboard on code blocks ] + +source_suffix = { + '.rst': 'restructuredtext', + '.md': 'markdown', +} + templates_path = ['_templates'] exclude_patterns = [] -# -- Options for HTML output ------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output +# -- Autodoc / Napoleon ------------------------------------------------------ + +autodoc_default_options = { + 'members': True, + 'undoc-members': True, + 'show-inheritance': True, + 'member-order': 'bysource', + # `handle_aliases` is a pydantic before-validator that autodoc can't + # introspect (it's wrapped in a PydanticDescriptorProxy). Hide it. + 'exclude-members': 'handle_aliases,model_config,model_fields,model_computed_fields', +} +autodoc_typehints = 'description' # render type hints into the param table +napoleon_google_docstring = True +napoleon_numpy_docstring = True +napoleon_include_init_with_doc = True + +# -- MyST (Markdown) --------------------------------------------------------- + +myst_enable_extensions = [ + 'colon_fence', # ::: admonition syntax + 'deflist', + 'html_admonition', + 'html_image', + 'tasklist', +] +myst_heading_anchors = 3 + +# Route ```mermaid fenced blocks through sphinxcontrib-mermaid so the existing +# `architecture.md` diagrams render visually instead of as raw code. +myst_fence_as_directive = ['mermaid'] + +# Don't fail on the intentional re-exports between `oshconnect.eventbus` +# and `oshconnect.events.core` (AtomicEventTypes is exposed at both names). +suppress_warnings = [ + 'duplicate_object_description', +] + +# -- Intersphinx ------------------------------------------------------------- + +intersphinx_mapping = { + 'python': ('https://docs.python.org/3', None), + 'pydantic': ('https://docs.pydantic.dev/latest', None), +} + +# -- Mermaid ----------------------------------------------------------------- + +mermaid_version = 'latest' + +# -- HTML output (Furo) ------------------------------------------------------ -html_theme = 'sphinx_rtd_theme' -html_static_path = ['_static'] +html_theme = 'furo' +# html_static_path is omitted — we don't ship custom CSS/JS yet. Add it +# back as ['_static'] (and create the directory) when there's something +# to put in there. +html_title = 'OSHConnect-Python' html_theme_options = { - 'sticky_navigation': True, - 'display_version': True, - 'prev_next_buttons_location': 'both', + 'sidebar_hide_name': False, + 'navigation_with_keys': True, + 'source_repository': 'https://github.com/Botts-Innovative-Research/OSHConnect-Python', + 'source_branch': 'main', + 'source_directory': 'docs/source/', + 'top_of_page_buttons': ['view', 'edit'], } diff --git a/docs/source/index.rst b/docs/source/index.rst index 380694c..101467c 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,20 +1,21 @@ -Welcome to OSHConnect-Python's documentation! -============================================= - OSHConnect-Python ================= -OSHConnect-Python is the Python version of the OSHConnect family of application libraries intended to provide a -simple and straightforward way to interact with OpenSensorHub (or another CS API server) by way of -OGC API - Connected Systems. -It supports Parts 1, 2, and 3 (Pub/Sub) of the OGC Connected Systems API, including: +OSHConnect-Python is the Python member of the OSHConnect family of application +libraries. It provides a simple, straightforward way to interact with +OpenSensorHub (or any other OGC API – Connected Systems server). + +It supports Parts 1, 2, and 3 (Pub/Sub) of the OGC Connected Systems API, +including: - System, Datastream, and ControlStream discovery and management -- Real-time MQTT streaming with CS API Part 3 ``:data`` topic conventions +- Real-time MQTT streaming using CS API Part 3 ``:data/`` topic conventions + (``swe-binary``, ``swe-json``, ``json``, …) - Resource event topic subscriptions (CloudEvents lifecycle notifications) - Batch retrieval and archival stream playback -- Configuration persistence (JSON save/load) +- Configuration persistence (JSON save / load) - SWE Common schema builders for defining datastream and command schemas +- OGC standard-format serialization (SML+JSON, OM+JSON, SWE+JSON, GeoJSON) All major classes and utilities are importable directly from ``oshconnect``. Lower-level CS API utilities are available from ``oshconnect.csapi4py``. @@ -23,14 +24,14 @@ Lower-level CS API utilities are available from ``oshconnect.csapi4py``. :maxdepth: 2 :caption: Contents + architecture/index tutorial api - Indices and tables ================== * :ref:`genindex` * :ref:`modindex` -* :ref:`search` \ No newline at end of file +* :ref:`search` diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index 7733825..1a96dbb 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -62,6 +62,68 @@ To connect a node with MQTT support for streaming: app.add_node(node) +Authentication +-------------- +OSHConnect speaks **HTTP Basic Auth** to OGC CS API servers. There is no +bearer-token, OAuth, or API-key flow — the underlying ``requests`` +library carries credentials as a ``(username, password)`` tuple. + +For a secured server, pass ``username`` and ``password`` to ``Node``: + +.. code-block:: python + + node = Node(protocol='https', address='sensors.example.org', port=443, + username='alice', password='s3cret') + +Every HTTP call the node makes — discovery, resource creation, schema +fetches — automatically carries those credentials. Internally, the node +constructs an ``APIHelper`` that holds the credentials and reads them +back via ``get_helper_auth()`` on each request. The same credentials +also flow into the MQTT client when ``enable_mqtt=True``. + +For an unsecured server (e.g., a local OSH dev instance), simply omit +``username`` and ``password``: + +.. code-block:: python + + node = Node(protocol='http', address='localhost', port=8585) + +If the server has been secured but you forget to provide credentials, +each request will return ``401 Unauthorized`` from the server — no +exception is raised by the library; inspect the response status. + +Lower-level usage (free helpers) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +For one-off scripts or when you don't want a full ``Node`` / +``OSHConnect`` setup, the module-level helpers in +``oshconnect.api_helpers`` mirror each CS API endpoint and accept an +optional ``auth`` tuple plus optional ``headers`` dict. Every helper +returns a ``requests.Response`` object: + +.. code-block:: python + + from oshconnect.api_helpers import list_all_systems, create_new_systems + + resp = list_all_systems( + 'http://sensors.example.org/sensorhub', + auth=('alice', 's3cret'), + ) + resp.raise_for_status() + systems = resp.json()['items'] + + created = create_new_systems( + 'http://sensors.example.org/sensorhub', + request_body={'name': 'Sensor #1', 'uid': 'urn:test:sensor:1'}, + auth=('alice', 's3cret'), + headers={'Content-Type': 'application/sml+json'}, + ) + new_id = created.headers['Location'].rsplit('/', 1)[-1] + +Omit ``auth`` to call an unsecured endpoint. For application code, +prefer the ``Node`` / ``APIHelper`` path so credentials are configured +once at the node boundary instead of threaded through every call site. + + Discovery --------- @@ -77,6 +139,68 @@ Discover all datastreams across all discovered systems: app.discover_datastreams() +Each discovered ``Datastream`` arrives with its SWE+JSON record schema +already cached on ``ds._underlying_resource.record_schema`` — discovery +makes a follow-up ``GET /datastreams/{id}/schema`` per stream so callers +that build observations don't need a second round trip. + +Discover control streams the same way, per system: + +.. code-block:: python + + for system in node.get_systems(): + control_streams = system.discover_controlstreams() + for cs in control_streams: + print(cs.get_id(), cs._underlying_resource.input_name) + +Discovered control streams arrive with their command schema cached on +``cs._underlying_resource.command_schema`` (a ``JSONCommandSchema`` — +OSH normalizes responses to the JSON envelope). Reach the inner SWE +Common component via ``cs._underlying_resource.command_schema.params_schema``; +its ``items`` (for ``DataChoice``) or ``fields`` (for ``DataRecord``) +list the parameters the stream accepts. + + +MQTT Topic Conventions +---------------------- +OSHConnect speaks the CS API Part 3 pub/sub conventions, including the +optional **format subtopic** that selects the wire format for each +Resource Data Topic. A subscription path looks like:: + + {mqtt_root}/datastreams/{ds_id}/observations:data/ + {mqtt_root}/controlstreams/{cs_id}/commands:data/ + {mqtt_root}/controlstreams/{cs_id}/status:data/json + +The trailing ```` is the hyphen-substituted MIME subtype +(``+`` is reserved as an MQTT wildcard and is disallowed in Kafka topic +names, so the server uses ``-`` instead): + +============================ ====================== +Content-type Topic token +============================ ====================== +``application/json`` ``json`` +``application/swe+json`` ``swe-json`` +``application/swe+binary`` ``swe-binary`` +``application/swe+csv`` ``swe-csv`` +``application/om+json`` ``om-json`` +``application/sml+json`` ``sml-json`` +============================ ====================== + +The Python client picks the right token for you. ``Datastream.init_mqtt`` +reads the discovered ``record_schema.obs_format`` (e.g. +``application/swe+binary`` for video datastreams) and appends +``/swe-binary`` to the data topic. ``ControlStream.init_mqtt`` does the +same with ``command_schema.command_format``, and the status topic is +always suffixed with ``/json`` since status payloads are JSON by +convention. If you build a topic manually via +``APIHelper.get_mqtt_topic`` you can pass ``format=...`` explicitly; an +unknown MIME type raises ``ValueError`` from +``oshconnect.csapi4py.mqtt.mqtt_topic_format_token`` so the client never +sends a token the server can't parse. + +Older servers that don't recognise the format subtopic still accept the +bare ``:data`` form — that's what ``init_mqtt`` produces when a +datastream has no fetched schema (the server's default format applies). Streaming Observations (MQTT) ------------------------------ @@ -177,6 +301,361 @@ Build a schema using SWE Common component classes, then attach it to a system: A ``TimeSchema`` must be the first field in the ``DataRecordSchema`` when targeting OpenSensorHub. +Working with SWE+Binary Datastreams +----------------------------------- +Some datastreams ship payloads that don't fit a JSON envelope — H.264 video +frames, JPEG snapshots, dense fixed-width records. For these the OGC CS API +defines ``application/swe+binary``: each observation is a packed byte +sequence whose layout is described by the datastream's ``recordEncoding`` +(a SWE Common ``BinaryEncoding``). + +OSHConnect parses these schemas automatically. When you call +``System.discover_datastreams()``, the SDK picks the schema obsFormat from +each datastream's advertised ``formats``: + +* ``application/swe+json`` if available (parsed as + ``SWEDatastreamRecordSchema``) +* otherwise ``application/swe+binary`` (parsed as + ``SWEBinaryDatastreamRecordSchema``) + +Decoding observations +~~~~~~~~~~~~~~~~~~~~~ + +For an existing binary datastream, ``Datastream.decode_observation(raw)`` +returns a dict keyed by field name. Block members (e.g. an H.264 frame) +come back as ``bytes`` — the SDK does not demux video codecs. + +.. code-block:: python + + # Assume `ds` is a Datastream whose schema is application/swe+binary, + # e.g. an Axis camera's `video1` output. + raw = bytes(ds._inbound_deque.popleft()) # one MQTT message + record = ds.decode_observation(raw) + ts = record['time'] # float — Unix epoch s + nal = record['img'] # bytes — opaque H.264 NAL unit + +Publishing binary observations +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For a binary datastream, ``Datastream.insert(...)`` dispatches through +``SWEBinaryCodec``, so you pass a dict keyed by field name (or a positional +sequence in declared member order) and the SDK packs it for you: + +.. code-block:: python + + # Pan/tilt record (fixed-width: [ts: double][f32][f32][f32]) + ds.insert({'time': time.time(), + 'pan': -6.7, 'tilt': 0.0, 'zoomFactor': 1.0}) + + # Video frame (variable-size block: [ts: double][size: uint32][N bytes]) + nal_bytes = grab_h264_nal_unit() # your codec, opaque to OSHConnect + ds.insert({'time': time.time(), 'img': nal_bytes}) + +You can also bypass the codec entirely by passing pre-encoded ``bytes`` — +useful when another component has already framed the record: + +.. code-block:: python + + from oshconnect.swe_binary import encode_swe_binary_blob + pre_framed = encode_swe_binary_blob(nal_bytes) + ds.insert(pre_framed) # passes through unchanged + +Building a binary datastream from scratch +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When registering a new binary datastream against an OSH node, build the +schema with ``SWEBinaryDatastreamRecordSchema`` and a ``BinaryEncoding`` +whose ``members`` list maps each record field to a wire shape: + +.. code-block:: python + + from oshconnect import DataRecordSchema, TimeSchema, QuantitySchema + from oshconnect.api_utils import URI, UCUMCode + from oshconnect.encoding import ( + BinaryComponentMember, BinaryEncoding, + ) + from oshconnect.schema_datamodels import SWEBinaryDatastreamRecordSchema + + record = DataRecordSchema( + name='ptz', label='PTZ Snapshot', + definition='http://example.org/ptz', + fields=[ + TimeSchema(name='time', label='Timestamp', + definition='http://www.opengis.net/def/property/OGC/0/SamplingTime', + uom=URI(href='http://www.opengis.net/def/uom/ISO-8601/0/Gregorian')), + QuantitySchema(name='pan', label='Pan', + definition='http://example.org/pan', + uom=UCUMCode(code='deg', label='degrees')), + ], + ) + encoding = BinaryEncoding( + byte_order='bigEndian', byte_encoding='raw', + members=[ + BinaryComponentMember( + ref='/time', + data_type='http://www.opengis.net/def/dataType/OGC/0/double'), + BinaryComponentMember( + ref='/pan', + data_type='http://www.opengis.net/def/dataType/OGC/0/float32'), + ], + ) + schema = SWEBinaryDatastreamRecordSchema( + obs_format='application/swe+binary', + record_schema=record, + record_encoding=encoding, + ) + +Block payloads (H.264, JPEG, etc.) are declared with +``BinaryBlockMember``; the ``compression`` attribute is metadata for +downstream consumers and is **not** acted on by the codec. + + +Working with SWE+Protobuf and SWE+FlatBuffers Datastreams +--------------------------------------------------------- +``application/swe+proto`` ships observations as Protocol Buffers +messages serialized against the SWE Common 3 schemas in the +`BinaryEncodings project `_. +``application/swe+flatbuffers`` is the FlatBuffers analogue. + +Why a separate encoding family from SWE+Binary? + +* **SWE+Binary** is a packed wire format for known-shape records (declared + per-field by `BinaryEncoding.members`). It's compact and demands no + schema-side runtime; it's also rigid — fields must be fixed-width or + size-prefixed blocks. +* **SWE+Protobuf** is self-describing tag-length-value bytes interpreted + through a code-generated schema (the ``sweCommon3_pb2`` module). It + handles nested records, choice variants, variable-length lists, and + field evolution naturally. The trade-off is the runtime dependency on + the generated bindings and slightly larger wire size for trivial records. + +Install requirements +~~~~~~~~~~~~~~~~~~~~ + +Install the optional extra and generate the bindings from BinaryEncodings: + +.. code-block:: bash + + pip install "oshconnect[protobuf]" + git clone https://github.com/tipatterson-dev/BinaryEncodings + cd BinaryEncodings && make protobuf PROTO_LANG=python + export PYTHONPATH="$PWD/gen/protobuf:$PYTHONPATH" + +The bindings are looked up via the standard Python import path — the +codec imports ``sweCommon3_pb2`` lazily on first use and raises a +descriptive ``ImportError`` (including the install hint) if they're +not available. + +Encoding and decoding observations +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``Datastream.insert(...)`` and ``decode_observation(...)`` dispatch on +the schema's ``obs_format`` exactly as they do for SWE+Binary: + +.. code-block:: python + + from oshconnect import ( + DataRecordSchema, TimeSchema, QuantitySchema, CountSchema, + BooleanSchema, TextSchema, + SWEProtobufDatastreamRecordSchema, + ) + from oshconnect.api_utils import URI, UCUMCode + + record = DataRecordSchema( + name='weather', label='Weather', + definition='http://example.org/weather', + fields=[ + TimeSchema(name='time', label='Time', + definition='http://www.opengis.net/def/property/OGC/0/SamplingTime', + uom=URI(href='http://www.opengis.net/def/uom/ISO-8601/0/Gregorian')), + QuantitySchema(name='temp', label='Temperature', + definition='http://example.org/temp', + uom=UCUMCode(code='Cel', label='Celsius')), + ], + ) + schema = SWEProtobufDatastreamRecordSchema(record_schema=record) + ds_resource.record_schema = schema # attach to a DatastreamResource + # Now `Datastream.insert({...})` packs values via SWEProtobufCodec + # and `Datastream.decode_observation(raw)` reverses it. + +Supported SWE Common 3 component types: ``Boolean``, ``Count``, +``Quantity``, ``Time``, ``Category``, ``Text``, ``DataRecord`` +(including nested), ``Vector``, ``DataChoice``, and ``DataArray`` +of scalar element types (Quantity, Count, Boolean, Time). + +DataArray wire format mirrors the OpenSensorHub reference +implementation (``BinaryDataWriter`` in +``lib-ogc/swe-common-core``): element values are packed tightly +back-to-back as SWE BinaryEncoding bytes (via +``oshconnect.swe_binary.encode_swe_binary_scalar_array``) and stuffed +in ``EncodedValues.inline_data``; the accompanying +``encoding.binary_encoding`` carries the dataType URI so the wire is +self-describing. Decoders can therefore read messages produced by any +SWE Common 3 implementation without needing the Python-side schema. + +``Matrix``, ``Geometry``, the ``*Range`` variants, and arrays of +records/vectors are not yet wired through the codec — using them +raises ``TypeError`` so the gap is explicit; extension is +straightforward via the dispatch table in ``oshconnect.swe_protobuf``. + +FlatBuffers status +~~~~~~~~~~~~~~~~~~ + +``application/swe+flatbuffers`` is wired through the same machinery +(`SWEFlatBuffersDatastreamRecordSchema` parses cleanly, the format +picker advertises the obsFormat, and ``Datastream.insert`` / +``decode_observation`` route to ``SWEFlatBuffersCodec``), but the codec +itself raises ``NotImplementedError`` until the FlatBuffers compiler +adds Python support for vectors of unions. See +``docs/osh_spec_deviations.md`` (``flatc-python-vector-of-union``). + + +Inserting a New Control Stream +------------------------------ +A control stream is the input counterpart to a datastream — it accepts +commands and emits status reports. Build a ``DataRecordSchema`` +describing the command structure, then attach it to a system via +``System.add_and_insert_control_stream(...)``: + +.. code-block:: python + + from oshconnect import DataRecordSchema, BooleanSchema, CountSchema + + command_record = DataRecordSchema( + name='counterControl', + label='Counter Control', + description='Commands to control the counter behavior', + fields=[ + BooleanSchema(name='setCountDown', label='Set Count Down', + definition='http://sensorml.com/ont/swe/property/SetCountDown'), + CountSchema(name='setStep', label='Set Step', + definition='http://sensorml.com/ont/swe/property/SetStep'), + ], + ) + + control_stream = new_system.add_and_insert_control_stream(command_record) + +The default wire form is ``application/json`` — +``commandFormat: "application/json"`` with a ``parametersSchema`` block +(no ``encoding``). It matches what OSH echoes back from +``GET /controlstreams/{id}/schema?f=json``, which is the form +``discover_controlstreams`` parses, so cross-node sync round-trips +without any format conversion. It also sidesteps the SWE+JSON +``encoding``-omission deviation documented in +``docs/osh_spec_deviations.md`` §1. + +For the spec-canonical SWE+JSON form (``recordSchema`` plus a +``JSONEncoding`` block), pass ``command_format='application/swe+json'``: + +.. code-block:: python + + control_stream = new_system.add_and_insert_control_stream( + command_record, + command_format='application/swe+json', + ) + +For full control over the resource body — for example, when copying a +control stream from one node to another and you already have a +``ControlStreamResource`` in hand — use ``add_insert_controlstream(...)`` +instead. It takes a fully-built resource and POSTs it as-is. Build the +embedded ``command_schema`` as a ``JSONCommandSchema`` for the +recommended JSON form: + +.. code-block:: python + + from oshconnect.resource_datamodels import ControlStreamResource + from oshconnect.schema_datamodels import JSONCommandSchema + + resource = ControlStreamResource( + name='Counter Control', + input_name='counterControl', + command_schema=JSONCommandSchema( + command_format='application/json', + params_schema=command_record, + ), + ) + control_stream = new_system.add_insert_controlstream(resource) + +After insert, the returned ``ControlStream`` carries the server-assigned +ID (``control_stream.get_id()``) and is appended to ``new_system.control_channels``. + + +Sending Commands +---------------- +A control stream is the input side of a system. Once you have one — either +freshly inserted or reconstructed from ``System.discover_controlstreams()`` — +there are two ways to deliver a command: + +**Over MQTT (preferred for real-time control).** Initialize the stream's +MQTT client, then publish to the command topic: + +.. code-block:: python + + from oshconnect import StreamableModes + + control_stream.set_connection_mode(StreamableModes.BIDIRECTIONAL) + control_stream.initialize() + control_stream.start() + + control_stream.publish_command({ + 'params': {'setStep': 5}, + }) + +``publish_command(payload)`` is sugar for ``publish(payload, topic='command')``; +it routes to the CS API Part 3 ``:commands`` topic for this stream +(``…/controlstreams/{id}/commands``). The payload shape is whatever the +control stream's command schema accepts — a dict matching the field names +under ``params``, or a SWE+JSON envelope if the stream uses the SWE form. + +**Over HTTP (stateless, one-shot).** POST a command directly to the +``/controlstreams/{id}/commands`` endpoint via the node's +``APIHelper``: + +.. code-block:: python + + from oshconnect.csapi4py.constants import APIResourceTypes + from oshconnect.schema_datamodels import CommandJSON + + command = CommandJSON(params={'setStep': 5}) + api = node.get_api_helper() + resp = api.create_resource( + APIResourceTypes.COMMAND, + command.to_csapi_dict(), + parent_res_id=control_stream.get_id(), + req_headers={'Content-Type': 'application/json'}, + ) + resp.raise_for_status() + command_id = resp.headers['Location'].rsplit('/', 1)[-1] + +The server responds with ``201 Created`` and a ``Location`` header pointing +at the newly-created command resource (``/commands/{id}``); poll its +``/status`` sub-resource (or subscribe to the MQTT status topic — next +section) to see whether the system accepted and executed it. + +Subscribing to Command Status +----------------------------- +Each control stream exposes two MQTT topics: ``/commands:data/`` +(input — the operator publishes here) and ``/status:data/json`` (output — +the system reports execution results here). See *MQTT topic conventions* +above for the format-token table. Subscribe to status updates: + +.. code-block:: python + + def on_status(client, userdata, msg): + print(f"Status on {msg.topic}: {msg.payload}") + + control_stream.subscribe(topic='status', callback=on_status) + +Inbound status reports are also pushed onto an internal deque — drain it +exactly like a datastream's inbound queue: + +.. code-block:: python + + while control_stream.get_status_deque_inbound(): + status = control_stream.get_status_deque_inbound().popleft() + print(status) + + Inserting an Observation ------------------------ Once a datastream is registered, send observation data using ``insert_observation_dict()``: diff --git a/examples/AXIS_MQTT_STREAM_README.md b/examples/AXIS_MQTT_STREAM_README.md new file mode 100644 index 0000000..165cf29 --- /dev/null +++ b/examples/AXIS_MQTT_STREAM_README.md @@ -0,0 +1,171 @@ +# Live MQTT video viewer demo + +`axis_video_mqtt_stream.py` connects to an OpenSensorHub (OSH) node, discovers +its video datastreams and control streams, and shows **one** live video panel +with two dropdowns: + +- **Video datastream** — every `application/swe+binary` H.264 video source on + the node. +- **Control stream** — every control stream on the node (the PTZ buttons + assume a pan/tilt/zoom rig). + +Switching a dropdown re-subscribes live — no restart. Both selections +round-trip through `axis_video_config.json` (written next to the script), so +the next launch restores them. + +--- + +## 1. Set up a Python environment + +The library targets **Python 3.12–3.14** (`requires-python = "<4.0,>=3.12"`). +The demo needs the optional **`[av]`** extra (PyAV for H.264 decode + Pillow) +and **tkinter** for the window. + +### With `uv` (recommended) + +From the repo root: + +```bash +uv sync --all-extras # installs the library + av/pillow + dev tools +uv run python examples/axis_video_mqtt_stream.py +``` + +To install only what the demo needs: + +```bash +uv pip install -e ".[av]" +uv run python examples/axis_video_mqtt_stream.py +``` + +### With plain `pip` / venv + +```bash +python3.12 -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e ".[av]" +python examples/axis_video_mqtt_stream.py +``` + +### tkinter + +tkinter ships with the python.org installer and most distro Python packages. +On Homebrew or pyenv builds you may need the system `tcl-tk` package: + +```bash +brew install tcl-tk # macOS / Homebrew +sudo apt install python3-tk # Debian / Ubuntu +``` + +If PyAV, Pillow, or tkinter are missing the script prints an install hint and +exits instead of crashing. + +--- + +## 2. What the OSH node must provide + +The demo is read-mostly: it subscribes to a video datastream over MQTT and +(optionally) publishes PTZ commands. For data to show up, the node needs: + +### Required — for video + +1. **MQTT enabled** on the node. The demo connects to the broker on + `localhost:1883` by default (CS API Part 3 Pub/Sub). +2. **At least one video datastream** whose observation schema is + `application/swe+binary` and exposes an **`img`** block member carrying + raw H.264 NAL units. This is the Axis/Amcrest driver convention; the demo + filters for exactly this shape (`is_swe_binary_video`) and ignores other + datastreams. +3. The server must support the **`:data/` format subtopic** added in + CS API Part 3 — the demo subscribes to + `…/datastreams//observations:data/swe-binary`, not bare `:data`. +4. The datastream must actually be **producing observations** — i.e. the + camera/RTP feed is connected and frames are buffered. An idle datastream + discovers fine but the panel stays on "waiting for first frame". + +### Optional — for PTZ control + +5. A **control stream** for the PTZ rig. The demo's buttons send relative + pan/tilt/zoom commands (`rpan`, `rtilt`, `rzoom`) as + `application/swe+json`, published to + `…/controlstreams//commands:data/swe-json`, and listen for acks on + `…/controlstreams//status:data/json`. A non-PTZ control stream can be + selected but the buttons won't mean anything to it. + +A typical source is the OSH Axis video driver (`osh-addons`), which registers +both the `video1` swe+binary datastream and a `ptzControl` control stream. + +--- + +## 3. Running it + +```bash +uv run python examples/axis_video_mqtt_stream.py +``` + +On launch it prints what it discovered and which streams it selected, e.g.: + +``` +Discovered 1 video datastream(s), 1 control stream(s). + video: Office Axis Video Camera · … - video1 (topic …/observations:data/swe-binary) + control: 02hqdbu6j4f0 (cmd …/commands:data/swe-json, status …/status:data/json) +``` + +Use the dropdowns to switch streams, the PTZ buttons to drive the rig, and the +**■ Stop** button (or closing the window) to exit cleanly. + +--- + +## 4. Configuration + +The **initial** video / control selection resolves in this order: + +1. `axis_video_config.json` (last saved selection), +2. environment defaults (`OSHC_AXIS_CAMERAS` first entry / `OSHC_PTZ_CS_ID`), +3. the first discovered entry. + +On startup the resolved pair is written back, so a hand-edited config pointing +at a stream no longer on the node is silently rewritten to the fallback (valid +ids are left untouched). The file is git-ignored — it's runtime state. + +### Environment variables + +| Variable | Default | Purpose | +|---|---|---| +| `OSHC_AXIS_HOST` | `localhost` | Server hostname / IP | +| `OSHC_AXIS_PORT` | `8282` | HTTP API port | +| `OSHC_AXIS_MQTT_PORT` | `1883` | MQTT broker port | +| `OSHC_AXIS_USER` / `OSHC_AXIS_PASS` | _(none)_ | HTTP Basic-Auth credentials | +| `OSHC_AXIS_CAMERAS` | _(none)_ | `Label:ds_id[,…]` — only the **first** id is used as the initial video default | +| `OSHC_PTZ_CS_ID` | _(none)_ | Control-stream id to pre-select | +| `OSHC_AXIS_CONFIG` | _beside script_ | Path to the selection config JSON | +| `OSHC_PTZ_PAN_STEP` / `OSHC_PTZ_TILT_STEP` / `OSHC_PTZ_ZOOM_STEP` | `5` / `2` / `1` | Relative step sizes for the PTZ buttons | +| `OSHC_AXIS_RUN_SECS` | `0` | Auto-exit after N seconds (`0` = run until closed); useful for headless checks | +| `OSHC_PTZ_AUTO` | _(off)_ | Fire a scripted PTZ sequence on launch (for headless verification) | +| `OSHC_LOG_LEVEL` | `INFO` | Logging verbosity | + +Example — point at a remote node with credentials and a 30-second timed run: + +```bash +OSHC_AXIS_HOST=10.0.0.5 OSHC_AXIS_USER=admin OSHC_AXIS_PASS=secret \ +OSHC_AXIS_RUN_SECS=30 uv run python examples/axis_video_mqtt_stream.py +``` + +--- + +## 5. Troubleshooting + +- **"no swe+binary video datastreams found"** — the node has no datastream + with a `swe+binary` schema + `img` block member, or discovery failed. Check + the node has a video driver registered and is reachable on the HTTP port. +- **Panel stuck on "waiting for first frame"** — datastream exists but no + frames are flowing (camera/RTP feed offline). The stats line shows + `nals=0`; the next H.264 keyframe usually recovers a stream that just + started. +- **`h264 decode` errors that clear themselves** — PyAV throws on inter-frames + before the first SPS/PPS keyframe lands; this is expected and self-recovers. +- **PTZ buttons do nothing / 500 errors** — the selected control stream isn't + a PTZ rig, or the driver rejects `swe+json` commands. The Axis `ptzControl` + driver only accepts `application/swe+json`, which is what the demo sends. +- **GUI won't open** — install tkinter (see §1). + +See the module docstring in `axis_video_mqtt_stream.py` for more detail. diff --git a/examples/axis_video_frame.py b/examples/axis_video_frame.py new file mode 100644 index 0000000..fb5ae92 --- /dev/null +++ b/examples/axis_video_frame.py @@ -0,0 +1,444 @@ +#!/usr/bin/env python +# ============================================================================= +# Copyright (c) 2026 Georobotix Innovative Research +# Date: 2026/5/19 +# Author: Ian Patterson +# Contact Email: ian.patterson@georobotix.us +# ============================================================================= + +"""End-to-end fidelity check for the SWE+binary codec against a live OSH node. + +For each configured camera datastream, the script pulls H.264 frames as +``application/swe+binary``, decodes each record with `SWEBinaryCodec`, +**re-encodes** them, and pops a tkinter window comparing the H.264 frame +decoded from the OSH node's raw bytes against the frame decoded after a +full encode→decode roundtrip through ``SWEBinaryCodec`` + +``encode_swe_binary_blob``. + +If the codec is faithful, the two panels on each row are pixel-identical +and the verdict label per camera reads "Byte-for-byte identical". The +GUI grows by one row per camera, so checking another camera is just one +more entry in `CAMERAS` (or one more ``label:ds_id`` in +``OSHC_AXIS_CAMERAS``). + +Defaults +-------- +* Node: ``http://localhost:9191/sensorhub/api`` +* Cameras: ``Axis -> 040g`` and ``Amcrest -> 025otg4indb0`` +* Frames: ``30`` per camera (enough to land a keyframe in practice) + +Override with: + +* ``OSHC_AXIS_PORT`` — server port (default ``9191``). +* ``OSHC_AXIS_FRAMES`` — frames per camera (default ``30``). +* ``OSHC_AXIS_CAMERAS`` — comma-separated ``Label:datastream_id`` pairs. + Example: ``OSHC_AXIS_CAMERAS=Axis:040g,Amcrest:025otg4indb0``. + +Run +--- + uv run python examples/axis_video_frame.py + +The side-by-side GUI needs PyAV (for H.264 decode) and Pillow; install +them via the ``[av]`` extra:: + + uv pip install -e ".[av]" + +tkinter ships with most Python distributions, including the python.org +installer; on Homebrew or pyenv builds you may need to install the +``tcl-tk`` system package. +""" +from __future__ import annotations + +import os +import struct +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +import requests + +from oshconnect.schema_datamodels import SWEBinaryDatastreamRecordSchema +from oshconnect.swe_binary import SWEBinaryCodec, encode_swe_binary_blob + + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +PORT = os.environ.get("OSHC_AXIS_PORT", "9191") +N_FRAMES = int(os.environ.get("OSHC_AXIS_FRAMES", "30")) +BASE_URL = f"http://localhost:{PORT}/sensorhub/api" +OUT_DIR = Path("examples/_out") + + +def _parse_camera_env(raw: str) -> list[tuple[str, str]]: + """Parse ``Label1:id1,Label2:id2`` into a list of (label, ds_id) tuples.""" + out: list[tuple[str, str]] = [] + for chunk in raw.split(","): + chunk = chunk.strip() + if not chunk: + continue + if ":" not in chunk: + raise ValueError( + f"OSHC_AXIS_CAMERAS entry {chunk!r} must be 'Label:datastream_id'.") + label, ds = chunk.split(":", 1) + out.append((label.strip(), ds.strip())) + return out + + +# Default camera lineup: both video sources currently registered on the test +# node. Override via OSHC_AXIS_CAMERAS to add/remove cameras without code +# changes — useful when the demo is run against a different node. +CAMERAS = _parse_camera_env( + os.environ.get("OSHC_AXIS_CAMERAS", "Axis:040g,Amcrest:025otg4indb0")) + + +# --------------------------------------------------------------------------- +# Per-camera result container +# --------------------------------------------------------------------------- + + +@dataclass +class CameraResult: + """Everything one camera produced — used to drive the GUI grid.""" + label: str + ds_id: str + schema: SWEBinaryDatastreamRecordSchema + codec: SWEBinaryCodec + n_records: int + frame_node: Optional["object"] # numpy ndarray + frame_codec: Optional["object"] # numpy ndarray + identical: bool + error: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def hex_window(label: str, raw: bytes, head: int = 16, tail: int = 8) -> None: + """Print a labelled byte window — first `head` bytes, then last `tail` + bytes — useful for visually comparing two payloads without scrolling + through 20 kB of H.264. + """ + if len(raw) <= head + tail: + print(f" {label} ({len(raw)} B): {raw.hex()}") + else: + print(f" {label} ({len(raw)} B): {raw[:head].hex()}…{raw[-tail:].hex()}") + + +def fetch_schema(ds_id: str) -> SWEBinaryDatastreamRecordSchema: + resp = requests.get( + f"{BASE_URL}/datastreams/{ds_id}/schema", + params={"obsFormat": "application/swe+binary"}, + timeout=5, + ) + resp.raise_for_status() + return SWEBinaryDatastreamRecordSchema.from_swebinary_dict(resp.json()) + + +def fetch_observations(ds_id: str, limit: int) -> bytes: + resp = requests.get( + f"{BASE_URL}/datastreams/{ds_id}/observations", + params={"f": "application/swe+binary", "limit": limit}, + timeout=10, + ) + resp.raise_for_status() + return resp.content + + +# --------------------------------------------------------------------------- +# Steps +# --------------------------------------------------------------------------- + + +def compare_round_trip(label: str, codec: SWEBinaryCodec, raw: bytes) -> bool: + """Decode → re-encode the first record; print + return byte-identity flag. + + Returns True if the codec produces byte-identical output for the first + record. The full per-stream identity is computed later in + `build_nal_streams`; this is the "fast confidence" check. + """ + print(f"\n=== {label}: round-trip fidelity check (first record) ===") + decoded, end = codec.decode_with_offset(raw, offset=0) + print(f"Decoded first record (consumed {end} bytes):") + print(f" time = {decoded['time']:.6f} (Unix epoch seconds)") + print(f" img = {len(decoded['img'])} bytes of H.264 NAL data") + print(f" NAL start code: {decoded['img'][:4].hex()} (expect 00000001)") + + reencoded = encode_swe_binary_blob(decoded["img"], ts=decoded["time"]) + original_window = raw[:end] + + print("\nByte comparison:") + hex_window("from node", original_window) + hex_window("our codec", reencoded) + if original_window == reencoded: + print("✓ Byte-for-byte identical.") + return True + print("✗ Mismatch — divergence positions:") + for i, (a, b) in enumerate(zip(original_window, reencoded)): + if a != b: + print(f" offset {i}: node=0x{a:02x} ours=0x{b:02x}") + if i > 16: + print(" …(truncated)") + break + if len(original_window) != len(reencoded): + print(f" length differs: node={len(original_window)} ours={len(reencoded)}") + return False + + +def save_nal_stream(codec: SWEBinaryCodec, raw: bytes, out_path: Path) -> int: + """Walk every record in `raw`, concatenate its NAL payload to `out_path`. + Returns the record count for sanity-printing.""" + out_path.parent.mkdir(parents=True, exist_ok=True) + count = 0 + offset = 0 + total = 0 + with out_path.open("wb") as f: + while offset < len(raw): + rec, offset = codec.decode_with_offset(raw, offset=offset) + f.write(rec["img"]) + total += len(rec["img"]) + count += 1 + print(f"Wrote {count} NAL units ({total} bytes) → {out_path}") + return count + + +def build_nal_streams(codec: SWEBinaryCodec, raw: bytes) -> tuple[bytes, bytes, int]: + """Walk every record twice — direct, and through the codec round-trip — to + produce parallel NAL byte streams. Returns (node_stream, codec_stream, n).""" + node_nals = bytearray() + codec_nals = bytearray() + offset = 0 + n_records = 0 + while offset < len(raw): + rec, offset = codec.decode_with_offset(raw, offset=offset) + node_nals += rec["img"] + reframed = encode_swe_binary_blob(rec["img"], ts=rec["time"]) + rec2, _ = codec.decode_with_offset(reframed, offset=0) + codec_nals += rec2["img"] + n_records += 1 + return bytes(node_nals), bytes(codec_nals), n_records + + +def _decode_first_frame(nal_bytes: bytes): + """Decode the first frame from an H.264 Annex B NAL stream. + + Returns an HxWx3 uint8 numpy array (RGB), or None if no frame could + be decoded. PyAV handles Annex B start-code framing natively so we + can feed the raw concatenated NAL bytes directly. + """ + import io + + import av # type: ignore + + try: + with av.open(io.BytesIO(nal_bytes), "r", format="h264") as container: + for frame in container.decode(video=0): + return frame.to_ndarray(format="rgb24") + except (OSError, ValueError) as exc: + # PyAV raises OSError / ValueError for invalid streams; older + # versions exposed `av.AVError` but it was removed in 11.x. + print(f" PyAV decode error: {exc}") + return None + return None + + +# --------------------------------------------------------------------------- +# Per-camera processing +# --------------------------------------------------------------------------- + + +def process_camera(label: str, ds_id: str, frames: int) -> CameraResult: + """Run the full fidelity check for one camera datastream. Returns a + `CameraResult` describing what was found, including decoded frames for + the GUI step. Errors are captured on the result rather than raised so a + failure for one camera doesn't kill the whole demo.""" + print(f"\n{'='*60}\n[{label}] datastream {ds_id}\n{'='*60}") + + schema = fetch_schema(ds_id) + members = [m.ref for m in schema.record_encoding.members] + print(f"✓ Fetched swe+binary schema; members: {members}") + codec = SWEBinaryCodec(schema) + + raw = fetch_observations(ds_id, limit=frames) + print(f"✓ Fetched {len(raw)} bytes ({frames} requested)") + if len(raw) == 0: + # OSH returns HTTP 200 with an empty body when the datastream has + # no buffered observations — typically because the driver hasn't + # connected to the source feed yet, or the source is offline. Treat + # this as a non-fatal "not ready yet" and continue with the other + # cameras. + msg = ("no observations available yet (camera offline, RTP feed " + "not connected, or no frames have been buffered)") + print(f"[{label}] SKIP: {msg}") + return CameraResult(label, ds_id, schema, codec, 0, None, None, + False, error=msg) + + try: + compare_round_trip(label, codec, raw) + except Exception as exc: # noqa: BLE001 + return CameraResult(label, ds_id, schema, codec, 0, None, None, + False, error=f"round-trip failed: {exc}") + + h264_path = OUT_DIR / f"{label.lower()}_frames.h264" + try: + save_nal_stream(codec, raw, h264_path) + except (struct.error, OSError) as exc: + print(f"WARNING: error while saving NAL stream: {exc}") + + try: + node_nals, codec_nals, n_records = build_nal_streams(codec, raw) + except Exception as exc: # noqa: BLE001 + return CameraResult(label, ds_id, schema, codec, 0, None, None, + False, error=f"stream build failed: {exc}") + identical = node_nals == codec_nals + print(f"\n[{label}] {n_records} records → {len(node_nals)} bytes per stream; " + f"identical: {identical}") + + print(f"[{label}] decoding first frame of each stream with PyAV…") + try: + frame_node = _decode_first_frame(node_nals) + frame_codec = _decode_first_frame(codec_nals) + except ImportError: + # GUI step requires PyAV; bare-bones runs without it still succeed. + frame_node = frame_codec = None + + return CameraResult(label, ds_id, schema, codec, n_records, + frame_node, frame_codec, identical) + + +# --------------------------------------------------------------------------- +# GUI — one row per camera, two panels per row, plus a verdict label +# --------------------------------------------------------------------------- + + +def show_side_by_side_gui(results: list[CameraResult]) -> None: + """Pop a tkinter window with one row per camera. Each row has two + panels: frame decoded straight from the OSH wire, and frame decoded + after a full encode→decode roundtrip through `SWEBinaryCodec`. A + per-camera verdict label sits between rows. + """ + try: + import tkinter as tk + + import av # noqa: F401 + from PIL import Image, ImageTk # type: ignore + except ImportError as exc: + print("\n(GUI display needs PyAV + Pillow + tkinter:") + print(f" {exc}") + print(" Install via: uv pip install -e '.[av]')") + return + + plottable = [r for r in results if r.frame_node is not None and r.frame_codec is not None] + skipped = [r for r in results if r not in plottable] + if not plottable: + print("\n(No decodable frames across the configured cameras; " + "skipping GUI.)") + for r in skipped: + print(f" - {r.label}: {r.error or 'no frame decoded'}") + return + + root = tk.Tk() + root.title("OSH cameras — SWE+binary codec fidelity") + container = tk.Frame(root, padx=12, pady=12) + container.pack() + + # Header + overall_ok = all(r.identical for r in plottable) + skip_note = (f" ({len(skipped)} skipped: " + f"{', '.join(r.label for r in skipped)})") if skipped else "" + header_text = (f"{len(plottable)} camera(s) plotted · " + f"verdict: " + f"{'✓ all identical' if overall_ok else '✗ mismatch detected'}" + f"{skip_note}") + header_color = "#1b8a3a" if overall_ok else "#b1331e" + tk.Label(container, text=header_text, + font=("Helvetica", 12, "bold"), fg=header_color).grid( + row=0, column=0, columnspan=2, pady=(0, 8)) + + tk.Label(container, text="From OSH node\n(direct H.264 decode)", + font=("Helvetica", 11, "bold")).grid(row=1, column=0, padx=6) + tk.Label(container, text="Through OSHConnect codec\n(decode → encode → decode)", + font=("Helvetica", 11, "bold")).grid(row=1, column=1, padx=6) + + # Hold image references on the root so they're not garbage-collected + # before tkinter renders them. + root._photo_refs = [] # type: ignore[attr-defined] + + target_w = 520 # smaller than the single-camera version so the column fits two rows + grid_row = 2 + for r in plottable: + h, w = r.frame_node.shape[:2] # type: ignore[union-attr] + scale = min(1.0, target_w / w) + new_size = (max(1, int(w * scale)), max(1, int(h * scale))) + + img_node = ImageTk.PhotoImage( + Image.fromarray(r.frame_node).resize(new_size)) + img_codec = ImageTk.PhotoImage( + Image.fromarray(r.frame_codec).resize(new_size)) + root._photo_refs.append((img_node, img_codec)) # type: ignore[attr-defined] + + tk.Label(container, image=img_node, borderwidth=2, relief="solid").grid( + row=grid_row, column=0, padx=6, pady=(8, 2)) + tk.Label(container, image=img_codec, borderwidth=2, relief="solid").grid( + row=grid_row, column=1, padx=6, pady=(8, 2)) + + verdict = ("✓ byte-for-byte identical" if r.identical + else "✗ mismatch") + color = "#1b8a3a" if r.identical else "#b1331e" + meta = (f"{r.label} · ds {r.ds_id} · {r.n_records} records · " + f"{w}×{h} → display {new_size[0]}×{new_size[1]} · {verdict}") + tk.Label(container, text=meta, font=("Helvetica", 10), fg=color).grid( + row=grid_row + 1, column=0, columnspan=2, pady=(0, 8)) + + grid_row += 2 + + tk.Label(container, text="Close the window to exit.", + font=("Helvetica", 9), fg="#666").grid( + row=grid_row, column=0, columnspan=2, pady=(4, 0)) + + root.mainloop() + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main() -> int: + print(f"Base URL: {BASE_URL}") + print(f"Cameras: {', '.join(f'{lbl}/{ds}' for lbl, ds in CAMERAS)}") + print(f"Frames: {N_FRAMES} per camera") + + results: list[CameraResult] = [] + for label, ds_id in CAMERAS: + try: + results.append(process_camera(label, ds_id, N_FRAMES)) + except Exception as exc: # noqa: BLE001 + # Don't let one bad camera kill the whole demo + print(f"\n[{label}] ERROR: {exc}") + results.append(CameraResult( + label, ds_id, None, None, 0, None, None, False, # type: ignore[arg-type] + error=str(exc))) + + print("\n" + "="*60) + print("Summary") + print("="*60) + for r in results: + if r.error: + print(f" {r.label} ({r.ds_id}): ERROR — {r.error}") + else: + print(f" {r.label} ({r.ds_id}): " + f"{r.n_records} records, " + f"{'identical' if r.identical else 'MISMATCH'}") + + show_side_by_side_gui(results) + print("\nDone.") + return 0 if all(r.error is None and r.identical for r in results) else 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/axis_video_mqtt_stream.py b/examples/axis_video_mqtt_stream.py new file mode 100644 index 0000000..2c85ff1 --- /dev/null +++ b/examples/axis_video_mqtt_stream.py @@ -0,0 +1,908 @@ +#!/usr/bin/env python +# ============================================================================= +# Copyright (c) 2026 Georobotix Innovative Research +# Date: 2026/5/21 +# Author: Ian Patterson +# Contact Email: ian.patterson@georobotix.us +# ============================================================================= + +"""Live MQTT video viewer with selectable datastream / control stream. + +Sibling to ``axis_video_frame.py``: that file pulls a fixed batch of +``application/swe+binary`` observations over HTTP and shows the codec is +byte-identical on round-trip. This one drives a camera datastream through +the full library end-to-end — `OSHConnect` discovery, `Node` with +``enable_mqtt=True``, a live MQTT subscription to the new +``…/observations:data/swe-binary`` topic — and decodes the incoming NAL +units live so the operator can actually see the camera moving. + +Unlike the earlier revision (which showed a fixed multi-camera grid driven +entirely by ``OSHC_AXIS_CAMERAS``), the viewer now shows **one** video +panel plus two dropdowns: + +* a **video datastream** dropdown listing every swe+binary video source + discovered on the node, and +* a **control stream** dropdown listing every control stream discovered on + the node (the PTZ buttons assume a PTZ rig — see the note on the panel). + +Picking a different entry re-subscribes live: the viewer unsubscribes the +old MQTT topic and subscribes the newly chosen one without restarting. The +two selections round-trip through a small JSON config file +(``axis_video_config.json`` beside this script, overridable via +``OSHC_AXIS_CONFIG``): the dropdowns are pre-selected from it on launch and +written back whenever they change. + +What it exercises +----------------- + +* The new CS API Part 3 ``:data/`` format subtopic — the video + datastream subscribes to its swe-binary subtopic, not bare ``:data``. +* `Datastream.decode_observation` on each MQTT message payload — same codec + the HTTP example uses, fed one record at a time from the broker. +* PyAV incremental decode of standalone H.264 NAL units (no container, + no Annex B parsing on our side — PyAV's parser handles framing). +* Live re-subscription when the operator switches streams from the GUI. + +Defaults +-------- +* Node: ``http://localhost:8282/sensorhub/api`` (HTTP) +* ``localhost:1883`` (MQTT broker on the same host) + +The initial selection resolves in this order: saved config → environment +defaults (``OSHC_AXIS_CAMERAS`` / ``OSHC_PTZ_CS_ID``) → first discovered +entry. On startup the resolved pair is written back, so a hand-edited +config that points at a stream no longer present on the node is silently +rewritten to the fallback (valid ids are left untouched). + +Override with: + +* ``OSHC_AXIS_HOST`` — server hostname/IP (default ``localhost``). +* ``OSHC_AXIS_PORT`` — HTTP API port (default ``8282``). +* ``OSHC_AXIS_MQTT_PORT`` — MQTT broker port (default ``1883``). +* ``OSHC_AXIS_USER`` / ``OSHC_AXIS_PASS`` — Basic-Auth credentials, if any. +* ``OSHC_AXIS_CAMERAS`` — comma-separated ``Label:datastream_id`` pairs; + only the first entry's id is used as the initial video default. +* ``OSHC_PTZ_CS_ID`` — control-stream id to pre-select. +* ``OSHC_AXIS_CONFIG`` — path to the selection config JSON. +* ``OSHC_AXIS_RUN_SECS`` — auto-exit after this many seconds (default + ``0`` = run until the window is closed). + +Run +--- + uv run python examples/axis_video_mqtt_stream.py + +Needs the ``[av]`` extra for H.264 decoding and tkinter for display:: + + uv pip install -e ".[av]" +""" +from __future__ import annotations + +import json +import logging +import os +import sys +import time +from collections import deque +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional + +from oshconnect import OSHConnect +from oshconnect.node import Node +from oshconnect.resources.base import StreamableModes +from oshconnect.resources.controlstream import ControlStream +from oshconnect.resources.datastream import Datastream +from oshconnect.schema_datamodels import ( + SWEBinaryDatastreamRecordSchema, + SWEJSONCommandSchema, +) + + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +HOST = os.environ.get("OSHC_AXIS_HOST", "localhost") +HTTP_PORT = int(os.environ.get("OSHC_AXIS_PORT", "8282")) +MQTT_PORT = int(os.environ.get("OSHC_AXIS_MQTT_PORT", "1883")) +USER = os.environ.get("OSHC_AXIS_USER") or None +PASS = os.environ.get("OSHC_AXIS_PASS") or None +RUN_SECS = float(os.environ.get("OSHC_AXIS_RUN_SECS", "0")) + +# Where the dropdown selections round-trip to. Beside this script by +# default so it doesn't depend on the working directory; override with +# OSHC_AXIS_CONFIG. This is runtime state, not a committed artifact. +CONFIG_PATH = Path( + os.environ.get("OSHC_AXIS_CONFIG", "") + or str(Path(__file__).with_name("axis_video_config.json"))) + +# Control-stream ID for the PTZ rig. Default ``""`` means auto-discover by +# inputName ("ptzControl"). Set OSHC_PTZ_CS_ID to pin a specific stream when +# multiple cameras live on the same node. +PTZ_CS_ID = os.environ.get("OSHC_PTZ_CS_ID", "").strip() or None +# Step sizes for the relative-motion buttons — small enough that auto-mode +# pans within the safe envelope without thrashing the gimbal. +PTZ_PAN_STEP = float(os.environ.get("OSHC_PTZ_PAN_STEP", "5")) +PTZ_TILT_STEP = float(os.environ.get("OSHC_PTZ_TILT_STEP", "2")) +PTZ_ZOOM_STEP = float(os.environ.get("OSHC_PTZ_ZOOM_STEP", "1")) +# When set, the GUI fires a scripted sequence of PTZ commands and exits +# (`rpan -PTZ_PAN_STEP`, `rpan +2·STEP`, `rpan -PTZ_PAN_STEP`, …) so the +# round-trip can be verified in CI / headless terminals. +PTZ_AUTO = os.environ.get("OSHC_PTZ_AUTO", "").lower() in ("1", "true", "yes") + + +def _parse_camera_env(raw: str) -> list[tuple[str, str]]: + """Parse ``Label1:id1,Label2:id2`` into a list of (label, ds_id) tuples.""" + out: list[tuple[str, str]] = [] + for chunk in raw.split(","): + chunk = chunk.strip() + if not chunk: + continue + if ":" not in chunk: + raise ValueError( + f"OSHC_AXIS_CAMERAS entry {chunk!r} must be 'Label:datastream_id'.") + label, ds = chunk.split(":", 1) + out.append((label.strip(), ds.strip())) + return out + + +# Only the first entry's datastream id is consulted, as the initial video +# default when no config file exists. Empty string → no env default. +_ENV_CAMERAS = _parse_camera_env(os.environ.get("OSHC_AXIS_CAMERAS", "")) +ENV_VIDEO_DS_ID = _ENV_CAMERAS[0][1] if _ENV_CAMERAS else None + + +# --------------------------------------------------------------------------- +# Selection config round-trip +# --------------------------------------------------------------------------- + + +def load_selection() -> dict: + """Read the saved ``{video_datastream_id, control_stream_id}`` selection. + + Returns an empty dict when the file is missing or unreadable — the + caller then falls back to environment defaults / first discovered entry. + """ + try: + with CONFIG_PATH.open("r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except FileNotFoundError: + return {} + except (OSError, ValueError) as exc: + logging.warning("Could not read selection config %s: %s", CONFIG_PATH, exc) + return {} + + +def save_selection(video_ds_id: Optional[str], control_cs_id: Optional[str]) -> None: + """Persist the current dropdown selections so the next launch restores + them. Best-effort: a write failure is logged, not raised — losing the + persisted choice should never take the live viewer down.""" + payload = { + "video_datastream_id": video_ds_id, + "control_stream_id": control_cs_id, + } + try: + with CONFIG_PATH.open("w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + logging.info("Saved selection to %s: %s", CONFIG_PATH, payload) + except OSError as exc: + logging.warning("Could not write selection config %s: %s", CONFIG_PATH, exc) + + +# --------------------------------------------------------------------------- +# Discovered-option containers +# --------------------------------------------------------------------------- + + +@dataclass +class VideoOption: + """One selectable video datastream, kept resolved so the dropdown + doesn't have to re-walk the system tree on every switch.""" + label: str + ds_id: str + datastream: Datastream + + +@dataclass +class ControlOption: + """One selectable control stream.""" + label: str + cs_id: str + controlstream: ControlStream + + +@dataclass +class Holder: + """Single-slot mutable reference. Lets GUI callbacks and the render + loop read the *current* active object after a live swap without + re-binding closures — the in-flight paho callback on a replaced object + simply writes to the now-detached instance, harmlessly.""" + current: Any = None + + +# --------------------------------------------------------------------------- +# Per-camera state +# --------------------------------------------------------------------------- + + +@dataclass +class CameraStream: + """Mutable state for one camera's live MQTT subscription.""" + label: str + ds_id: str + datastream: Optional[Datastream] = None + # PyAV CodecContext handle (typed as ``object`` to avoid an import-time + # PyAV dep on this file when the user only wants to read the source). + codec_ctx: Optional["object"] = None + # Per-camera frame queue: producer is the PyAV decode step (running in + # the paho network thread); consumer is the tkinter render step. + # A deque with maxlen=1 means "drop intermediate frames if the GUI + # falls behind" — preferred over backing up. + latest_frame: deque = field(default_factory=lambda: deque(maxlen=1)) + nals_received: int = 0 + frames_decoded: int = 0 + last_error: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Setup helpers +# --------------------------------------------------------------------------- + + +def _system_label(system) -> str: + """Display name for a `System` — its ``label`` (the CS API/SML display + string), falling back to the resource id. Avoids `System.name`, which + is deprecated.""" + return (getattr(system, "label", None) + or getattr(system, "_resource_id", None) or "system") + + +def _system_id(system) -> str: + """Server-side id of a `System` (``_resource_id``).""" + return getattr(system, "_resource_id", None) or "?" + + +def connect_and_discover() -> tuple[OSHConnect, Node]: + """Build an `OSHConnect` with one `Node` (MQTT enabled), discover the + full system / datastream / control-stream tree, and return both for + downstream wiring.""" + osh = OSHConnect(name="axis-mqtt-viewer") + node = Node( + protocol="http", + address=HOST, + port=HTTP_PORT, + username=USER, + password=PASS, + enable_mqtt=True, + mqtt_port=MQTT_PORT, + ) + osh.add_node(node) + osh.discover_systems() + # Datastream + control-stream discovery is per-system. + for system in node._systems: + try: + system.discover_datastreams() + except Exception as exc: # noqa: BLE001 + logging.error("Datastream discovery failed for system %s: %s", + _system_id(system), exc) + try: + system.discover_controlstreams() + except Exception as exc: # noqa: BLE001 + logging.error("ControlStream discovery failed for system %s: %s", + _system_id(system), exc) + return osh, node + + +def is_swe_binary_video(ds: Datastream) -> bool: + """A datastream is treated as a binary video source if its record + schema is `SWEBinaryDatastreamRecordSchema` and exposes an ``img`` + block member (the Axis driver convention).""" + schema = getattr(ds.get_resource(), "record_schema", None) + if not isinstance(schema, SWEBinaryDatastreamRecordSchema): + return False + members = getattr(getattr(schema, "record_encoding", None), "members", []) + return any( + getattr(m, "ref", "").endswith("/img") or getattr(m, "ref", "") == "img" + for m in members + ) + + +def discover_video_options(node: Node) -> list[VideoOption]: + """Walk every system on the node and return one `VideoOption` per + swe+binary video datastream, labelled `` · ``.""" + out: list[VideoOption] = [] + for system in node._systems: + sys_name = _system_label(system) + for ds in system.datastreams: + if not is_swe_binary_video(ds): + continue + ds_name = getattr(ds.get_resource(), "name", "") or ds.get_id() + out.append(VideoOption(label=f"{sys_name} · {ds_name}", + ds_id=ds.get_id(), datastream=ds)) + return out + + +def discover_control_options(node: Node) -> list[ControlOption]: + """Return one `ControlOption` per discovered control stream, labelled + `` · ``. PTZ-style streams (``inputName == + 'ptzControl'``) sort first so the default selection lands on one.""" + out: list[ControlOption] = [] + for system in node._systems: + sys_name = _system_label(system) + for cs in system.control_channels: + res = cs.get_underlying_resource() + input_name = getattr(res, "input_name", "") or "" + cs_name = getattr(res, "name", "") or cs.get_id() + label = f"{sys_name} · {cs_name}" + if input_name and input_name not in label: + label += f" [{input_name}]" + out.append(ControlOption(label=label, cs_id=cs.get_id(), + controlstream=cs)) + out.sort(key=lambda o: 0 if "ptzControl" in o.label else 1) + return out + + +def build_codec_context(): + """Create a fresh PyAV H.264 decoder context. Imported lazily so the + file can be inspected without the [av] extra installed.""" + import av # type: ignore + + ctx = av.codec.CodecContext.create("h264", "r") + return ctx + + +# --------------------------------------------------------------------------- +# Initial-selection resolution +# --------------------------------------------------------------------------- + + +def _pick_initial(options: list, saved_id: Optional[str], env_id: Optional[str], + id_attr: str): + """Resolve the initial selection: saved config id → env default id → + first option. Returns the chosen option (or None when ``options`` is + empty).""" + by_id = {getattr(o, id_attr): o for o in options} + if saved_id and saved_id in by_id: + return by_id[saved_id] + if env_id and env_id in by_id: + return by_id[env_id] + return options[0] if options else None + + +# --------------------------------------------------------------------------- +# PTZ control wiring +# --------------------------------------------------------------------------- + + +@dataclass +class PtzControl: + """Live PTZ control surface plus the last-command/last-status display + strings the GUI binds to.""" + controlstream: ControlStream + last_command: str = "(none)" + last_status: str = "(no status yet)" + commands_sent: int = 0 + status_msgs: int = 0 + + +def setup_ptz_control(cs: ControlStream) -> PtzControl: + """Wire a discovered ControlStream for live PTZ driving. + + Forces its command_format to ``application/swe+json`` (Axis only parses + commands in that wire form; ``application/json`` returns 500 on this + driver), initializes MQTT, derives the status topic, and returns a + `PtzControl` for the GUI to drive. + """ + # Override the discovered JSONCommandSchema with the swe+json variant + # so init_mqtt() picks the /swe-json topic suffix — the only format + # the Axis ptzControl driver actually accepts. Use model_construct + # to skip the (otherwise-required) `encoding` / `record_schema` fields + # we don't need just to drive the topic suffix. + cs._underlying_resource.command_schema = SWEJSONCommandSchema.model_construct( + command_format="application/swe+json", + ) + # Rebuild the topic strings now that the command_format changed. + # _status_topic was set in __init__ before we overrode the schema, so + # re-derive both — command topic via init_mqtt, status topic via the + # explicit helper. + cs.set_connection_mode(StreamableModes.BIDIRECTIONAL) + cs.initialize() + cs._status_topic = cs.get_mqtt_status_topic() + + logging.info("[PTZ] command topic: %s", cs._topic) + logging.info("[PTZ] status topic: %s", cs._status_topic) + + return PtzControl(controlstream=cs) + + +def send_ptz(ptz: Optional[PtzControl], **fields: float) -> None: + """Publish one PTZ command. ``fields`` is a single-key dict like + ``{"rpan": 5.0}`` per the DataChoice schema — passing more than one + key still works on the wire but only the first option in the choice + is meaningful to the Axis driver. No-ops when no control stream is + selected.""" + if ptz is None or not fields: + return + payload = json.dumps(fields).encode("utf-8") + cs = ptz.controlstream + try: + cs.publish_command(payload) + except Exception as exc: # noqa: BLE001 + ptz.last_command = f"ERROR: {exc}" + logging.error("PTZ publish failed: %s", exc) + return + ptz.commands_sent += 1 + ptz.last_command = ", ".join(f"{k}={v}" for k, v in fields.items()) + logging.info("[PTZ] sent %s -> %s", ptz.last_command, cs._topic) + + +def attach_ptz_status_subscriber(ptz: PtzControl) -> None: + """Subscribe to the PTZ status topic and store the latest payload on + `ptz.last_status` so the GUI can show command acks live.""" + cs = ptz.controlstream + if cs._mqtt_client is None: + return + + def _on_status(client, userdata, msg): + ptz.status_msgs += 1 + try: + decoded = msg.payload.decode("utf-8", errors="replace") + except Exception: # noqa: BLE001 + ptz.last_status = repr(msg.payload[:80]) + return + # Pull just the keys the operator cares about. Slicing the raw + # JSON lands mid-token on long payloads (e.g. chops the 's' off + # "statusCode"), so parse properly first and fall back to a + # head-truncated raw view only when parsing fails. + try: + obj = json.loads(decoded) + code = obj.get("statusCode") or obj.get("currentStatus") or "?" + cmd_id = obj.get("command@id") or obj.get("commandId") or obj.get("id") or "" + exec_time = obj.get("executionTime") + if isinstance(exec_time, list) and exec_time: + exec_time = exec_time[-1] + parts = [f"statusCode={code}"] + if cmd_id: + parts.append(f"cmd={cmd_id}") + if exec_time: + parts.append(f"at={exec_time}") + ptz.last_status = " ".join(parts) + except (ValueError, TypeError): + ptz.last_status = decoded[:120] + ("…" if len(decoded) > 120 else "") + + cs._mqtt_client.subscribe(cs._status_topic, msg_callback=_on_status) + + +def switch_control(ptz_holder: Holder, option: Optional[ControlOption]) -> None: + """Tear down the currently-wired PTZ control (if any) and bring up the + one named by ``option``. Called from the GUI thread on dropdown change + — paho sub/unsubscribe are thread-safe.""" + old: Optional[PtzControl] = ptz_holder.current # type: ignore[assignment] + if old is not None and old.controlstream._mqtt_client is not None: + try: + old.controlstream._mqtt_client.unsubscribe(old.controlstream._status_topic) + except Exception as exc: # noqa: BLE001 + logging.warning("Failed to unsubscribe old PTZ status topic: %s", exc) + + if option is None: + ptz_holder.current = None + return + + ptz = setup_ptz_control(option.controlstream) + attach_ptz_status_subscriber(ptz) + ptz_holder.current = ptz + + +# --------------------------------------------------------------------------- +# MQTT → frame dispatch +# --------------------------------------------------------------------------- + + +def make_msg_callback(cam: CameraStream): + """Build a paho-mqtt message callback for one camera. + + Captures `cam` in the closure so we don't need a topic→camera lookup + inside the callback hot path. The callback runs on paho's network + thread, so it must not touch tkinter — we only decode here and push + the resulting RGB ndarray onto `cam.latest_frame` for the GUI thread + to consume. + """ + def _on_msg(client, userdata, msg): + cam.nals_received += 1 + try: + record = cam.datastream.decode_observation(msg.payload) + except Exception as exc: # noqa: BLE001 + cam.last_error = f"swe-binary decode: {exc}" + return + + nal_bytes = record.get("img") + if not nal_bytes: + return + + try: + import av # type: ignore + packet = av.Packet(nal_bytes) + frames = cam.codec_ctx.decode(packet) + except Exception as exc: # noqa: BLE001 + # PyAV can throw on malformed NALs or before SPS/PPS lands — + # capture and continue, the next keyframe usually recovers. + cam.last_error = f"h264 decode: {exc}" + return + + for frame in frames: + try: + rgb = frame.to_ndarray(format="rgb24") + except Exception as exc: # noqa: BLE001 + cam.last_error = f"frame->ndarray: {exc}" + continue + cam.frames_decoded += 1 + cam.latest_frame.append(rgb) + + return _on_msg + + +def subscribe_video(option: VideoOption) -> CameraStream: + """Resolve a `VideoOption` to a freshly-wired `CameraStream` and start + its MQTT subscription. State (codec context, counters) is brand new so + a switched-to stream starts clean rather than inheriting the previous + camera's error text.""" + cam = CameraStream(label=option.label, ds_id=option.ds_id) + ds = option.datastream + try: + cam.codec_ctx = build_codec_context() + except ImportError: + cam.last_error = ( + "PyAV not installed — `uv pip install -e '.[av]'` to enable " + "live H.264 decode") + return cam + cam.datastream = ds + + # PULL is the only mode that actually calls subscribe() inside + # Datastream.start(); without this the start path tries to spawn an + # async write task instead. + ds.set_connection_mode(StreamableModes.PULL) + ds.initialize() + + logging.info("[%s] subscribing to MQTT topic: %s", cam.label, ds._topic) + # We want our custom callback, not the default deque-append, so call + # subscribe directly rather than ds.start(). + ds._mqtt_client.subscribe(ds._topic, msg_callback=make_msg_callback(cam)) + return cam + + +def switch_video(cam_holder: Holder, option: Optional[VideoOption]) -> None: + """Unsubscribe the currently-streaming datastream (if any) and subscribe + the one named by ``option``. Called from the GUI thread on dropdown + change.""" + old: Optional[CameraStream] = cam_holder.current # type: ignore[assignment] + if old is not None and old.datastream is not None: + try: + old.datastream._mqtt_client.unsubscribe(old.datastream._topic) + except Exception as exc: # noqa: BLE001 + logging.warning("Failed to unsubscribe old video topic: %s", exc) + + cam_holder.current = subscribe_video(option) if option is not None else None + + +# --------------------------------------------------------------------------- +# GUI +# --------------------------------------------------------------------------- + + +def _build_ptz_panel(parent, ptz_holder: Holder, status_var, cmd_var): + """Build the PTZ control row. The directional buttons read the *current* + control stream out of `ptz_holder` each time they fire, so they keep + working after a live control-stream switch.""" + import tkinter as tk + + frame = tk.Frame(parent, padx=8, pady=8, borderwidth=1, relief="groove") + tk.Label(frame, text="PTZ controls (assume a PTZ rig)", + font=("Helvetica", 11, "bold")).grid( + row=0, column=0, columnspan=8, sticky="w") + + # Row of directional / zoom buttons. Pan and tilt are *relative* so the + # operator can nudge without knowing the current absolute pose; zoom + # uses the relative `rzoom` knob for the same reason. Each lambda reads + # ptz_holder.current at click time — not a captured PtzControl. + btn_specs = [ + ("◀ pan-", lambda: send_ptz(ptz_holder.current, rpan=-PTZ_PAN_STEP)), + ("pan+ ▶", lambda: send_ptz(ptz_holder.current, rpan=+PTZ_PAN_STEP)), + ("▲ tilt+", lambda: send_ptz(ptz_holder.current, rtilt=+PTZ_TILT_STEP)), + ("tilt- ▼", lambda: send_ptz(ptz_holder.current, rtilt=-PTZ_TILT_STEP)), + ("zoom −", lambda: send_ptz(ptz_holder.current, rzoom=-PTZ_ZOOM_STEP)), + ("zoom +", lambda: send_ptz(ptz_holder.current, rzoom=+PTZ_ZOOM_STEP)), + ("⌂ home", lambda: send_ptz(ptz_holder.current, pan=0.0)), + ] + for col, (label, cb) in enumerate(btn_specs): + tk.Button(frame, text=label, width=9, command=cb).grid( + row=1, column=col, padx=2, pady=4) + + tk.Label(frame, textvariable=cmd_var, font=("Helvetica", 10), + fg="#1b8a3a").grid(row=2, column=0, columnspan=8, sticky="w") + tk.Label(frame, textvariable=status_var, font=("Helvetica", 9), + fg="#555", wraplength=720, justify="left").grid( + row=3, column=0, columnspan=8, sticky="w") + return frame + + +def _schedule_ptz_auto(root, ptz_holder: Holder) -> None: + """Fire a small scripted PTZ sequence so the example can be verified + headlessly. Each step is debounced so command/status traffic doesn't + pile up on the broker.""" + steps = [ + ("nudge pan +", lambda: send_ptz(ptz_holder.current, rpan=+PTZ_PAN_STEP)), + ("nudge pan -", lambda: send_ptz(ptz_holder.current, rpan=-PTZ_PAN_STEP)), + ("nudge tilt -", lambda: send_ptz(ptz_holder.current, rtilt=-PTZ_TILT_STEP)), + ("nudge tilt +", lambda: send_ptz(ptz_holder.current, rtilt=+PTZ_TILT_STEP)), + ("home", lambda: send_ptz(ptz_holder.current, pan=0.0)), + ] + delay_ms = 1200 + for i, (label, cb) in enumerate(steps): + def _fire(label=label, cb=cb): + logging.info("[PTZ-AUTO] %s", label) + cb() + root.after(800 + i * delay_ms, _fire) + + +def run_gui(video_options: list[VideoOption], + control_options: list[ControlOption], + cam_holder: Holder, + ptz_holder: Holder, + stop_after: float = 0.0) -> int: + """Block on a tkinter window: one video panel, a video-datastream + dropdown, a control-stream dropdown, and the PTZ control row. Switching + a dropdown re-subscribes live and writes the new pair to the config + file. Returns process exit code (0 if any frame decoded, else 2).""" + try: + import tkinter as tk + from tkinter import ttk + + from PIL import Image, ImageTk # type: ignore + except ImportError as exc: + print("GUI needs Pillow + tkinter:", exc) + print("Install via: uv pip install -e '.[av]'") + return 2 + + root = tk.Tk() + root.title("OSH camera — live MQTT video (swe+binary) + PTZ") + container = tk.Frame(root, padx=12, pady=12) + container.pack() + + tk.Label(container, + text=("Live frames decoded from MQTT swe-binary messages. " + "Pick a datastream / control stream below — selections " + "round-trip through the config file."), + font=("Helvetica", 11, "bold")).grid( + row=0, column=0, columnspan=2, pady=(0, 10)) + + # --- selection row: two dropdowns ------------------------------------- + sel = tk.Frame(container) + sel.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(0, 8)) + + video_by_label = {o.label: o for o in video_options} + control_by_label = {o.label: o for o in control_options} + + tk.Label(sel, text="Video datastream:").grid(row=0, column=0, sticky="w", padx=(0, 6)) + video_var = tk.StringVar() + video_box = ttk.Combobox(sel, textvariable=video_var, state="readonly", + width=44, values=list(video_by_label.keys())) + video_box.grid(row=0, column=1, sticky="w", pady=2) + + tk.Label(sel, text="Control stream:").grid(row=1, column=0, sticky="w", padx=(0, 6)) + control_var = tk.StringVar() + control_box = ttk.Combobox(sel, textvariable=control_var, state="readonly", + width=44, values=list(control_by_label.keys())) + control_box.grid(row=1, column=1, sticky="w", pady=2) + + # Reflect the already-resolved initial selection in the widgets. + if cam_holder.current is not None: + video_var.set(cam_holder.current.label) # type: ignore[union-attr] + elif not video_options: + video_var.set("(no swe+binary video datastreams found)") + if ptz_holder.current is not None: + cur_cs_id = ptz_holder.current.controlstream.get_id() # type: ignore[union-attr] + for o in control_options: + if o.cs_id == cur_cs_id: + control_var.set(o.label) + break + elif not control_options: + control_var.set("(no control streams found)") + + def _current_ids() -> tuple[Optional[str], Optional[str]]: + v = cam_holder.current.ds_id if cam_holder.current is not None else None # type: ignore[union-attr] + c = (ptz_holder.current.controlstream.get_id() # type: ignore[union-attr] + if ptz_holder.current is not None else None) + return v, c + + def _on_video_selected(_event=None): + option = video_by_label.get(video_var.get()) + switch_video(cam_holder, option) + v, c = _current_ids() + save_selection(v, c) + + def _on_control_selected(_event=None): + option = control_by_label.get(control_var.get()) + switch_control(ptz_holder, option) + v, c = _current_ids() + save_selection(v, c) + + video_box.bind("<>", _on_video_selected) + control_box.bind("<>", _on_control_selected) + + # --- video panel ------------------------------------------------------ + target_w = 640 + panel = tk.Frame(container) + panel.grid(row=2, column=0, columnspan=2) + img_label = tk.Label(panel, borderwidth=2, relief="solid", + width=target_w // 8, height=target_w // 14) + img_label.grid(row=0, column=0, pady=(4, 4)) + stats_label = tk.Label(panel, text="(waiting for first frame)", + font=("Helvetica", 10), fg="#555") + stats_label.grid(row=1, column=0) + + # --- PTZ control row -------------------------------------------------- + cmd_var = tk.StringVar(value="Last command: (none)") + status_var = tk.StringVar(value="Last status: (none)") + ptz_panel = _build_ptz_panel(container, ptz_holder, status_var, cmd_var) + ptz_panel.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(12, 0)) + + # --- Stop button ------------------------------------------------------ + # Quitting the mainloop drops out of run_gui into main()'s finally + # block, which disconnects MQTT cleanly — same path as the window-close + # handler, so closing the window and clicking Stop behave identically. + tk.Button(container, text="■ Stop", width=12, fg="#b1331e", + command=root.quit).grid(row=4, column=0, columnspan=2, + pady=(12, 0)) + + # Keep a strong reference on the root so tkinter doesn't GC the + # PhotoImages between ticks. + photo_refs: list = [] + root._photo_refs = photo_refs # type: ignore[attr-defined] + + start_wall = time.monotonic() + + def tick(): + cam: Optional[CameraStream] = cam_holder.current # type: ignore[assignment] + if cam is None: + stats_label.config(text="(no video datastream selected)", fg="#555") + elif cam.last_error and cam.frames_decoded == 0: + stats_label.config(text=f"{cam.label} — {cam.last_error}", fg="#b1331e") + else: + if cam.latest_frame: + rgb = cam.latest_frame.popleft() + h, w = rgb.shape[:2] + scale = min(1.0, target_w / w) + new_size = (max(1, int(w * scale)), max(1, int(h * scale))) + photo = ImageTk.PhotoImage(Image.fromarray(rgb).resize(new_size)) + img_label.config(image=photo, width=new_size[0], height=new_size[1]) + photo_refs.append(photo) + # Trim the cache so we don't grow without bound. + if len(photo_refs) > 4: + del photo_refs[:2] + err_note = f" · last error: {cam.last_error}" if cam.last_error else "" + stats_label.config( + text=(f"{cam.label} · nals={cam.nals_received} " + f"frames={cam.frames_decoded}{err_note}"), + fg=("#1b8a3a" if cam.frames_decoded > 0 else "#555")) + + ptz: Optional[PtzControl] = ptz_holder.current # type: ignore[assignment] + if ptz is not None: + cmd_var.set(f"Last command: {ptz.last_command} " + f"(sent={ptz.commands_sent})") + status_var.set(f"Last status [{ptz.status_msgs}]: {ptz.last_status}") + else: + cmd_var.set("Last command: (no control stream selected)") + status_var.set("Last status: —") + + if stop_after > 0 and (time.monotonic() - start_wall) >= stop_after: + root.quit() + else: + root.after(40, tick) + + if PTZ_AUTO and ptz_holder.current is not None: + _schedule_ptz_auto(root, ptz_holder) + + root.after(40, tick) + root.protocol("WM_DELETE_WINDOW", root.quit) + root.mainloop() + root.destroy() + + cam = cam_holder.current # type: ignore[assignment] + return 0 if (cam is not None and cam.frames_decoded > 0) else 2 + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main() -> int: + logging.basicConfig( + level=os.environ.get("OSHC_LOG_LEVEL", "INFO"), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + print(f"Node: http://{HOST}:{HTTP_PORT} (MQTT :{MQTT_PORT})") + print(f"Config: {CONFIG_PATH}") + + osh, node = connect_and_discover() + video_options = discover_video_options(node) + control_options = discover_control_options(node) + print(f"Discovered {len(video_options)} video datastream(s), " + f"{len(control_options)} control stream(s).") + + saved = load_selection() + initial_video = _pick_initial( + video_options, saved.get("video_datastream_id"), ENV_VIDEO_DS_ID, "ds_id") + initial_control = _pick_initial( + control_options, saved.get("control_stream_id"), PTZ_CS_ID, "cs_id") + + # Resolve the initial selection BEFORE the window / PTZ_AUTO script so a + # headless run actually has a stream to drive. + cam_holder = Holder() + ptz_holder = Holder() + switch_video(cam_holder, initial_video) + switch_control(ptz_holder, initial_control) + # Persist the resolved pair so the config file reflects what's live even + # on a first run with no prior config. + if initial_video is not None or initial_control is not None: + save_selection( + initial_video.ds_id if initial_video else None, + initial_control.cs_id if initial_control else None) + + if cam_holder.current is not None: + print(f" video: {cam_holder.current.label} " # type: ignore[union-attr] + f"(topic {cam_holder.current.datastream._topic})") # type: ignore[union-attr] + else: + print(" video: (none selected)") + if ptz_holder.current is not None: + cs = ptz_holder.current.controlstream # type: ignore[union-attr] + print(f" control: {cs.get_id()} (cmd {cs._topic}, status {cs._status_topic})") + else: + print(" control: (none selected)") + + # Small grace period so SPS/PPS NALs land before the GUI opens — not + # strictly required (the decoder catches up at the next keyframe) but + # it makes the first second of the demo look better. + time.sleep(1.0) + + try: + rc = run_gui(video_options, control_options, + cam_holder, ptz_holder, stop_after=RUN_SECS) + finally: + # paho-mqtt's network loop is daemonized via loop_start(), so + # process exit cleans it up — but disconnect cleanly anyway so the + # broker sees a graceful close instead of a TCP RST. + client = node.get_mqtt_client() + if client is not None: + try: + client.stop() + client.disconnect() + except Exception: # noqa: BLE001 + pass + + print("\nSummary:") + cam = cam_holder.current + if cam is None: + print(" video: (none selected)") + elif cam.last_error and cam.frames_decoded == 0: + print(f" video: {cam.label} ({cam.ds_id}): ERROR — {cam.last_error}") + else: + print(f" video: {cam.label} ({cam.ds_id}): " + f"{cam.nals_received} NALs, {cam.frames_decoded} frames decoded" + f"{' (' + cam.last_error + ')' if cam.last_error else ''}") + ptz = ptz_holder.current + if ptz is not None: + print(f" control: {ptz.controlstream.get_id()}: " + f"{ptz.commands_sent} commands sent, " + f"{ptz.status_msgs} status messages received " + f"(last: {ptz.last_status})") + return rc + + +if __name__ == "__main__": + # Silence noisy paho debug logging unless the user explicitly cranks + # the level via OSHC_LOG_LEVEL. + logging.getLogger("paho").setLevel(logging.WARNING) + # Ensure no leftover background threads hold the process up. + sys.exit(main()) \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index 6db1be9..0000000 --- a/mkdocs.yml +++ /dev/null @@ -1,71 +0,0 @@ -site_name: OSHConnect-Python -site_description: Python library for the OGC API – Connected Systems (Parts 1, 2, and 3 Pub/Sub) -site_author: Ian Patterson -repo_url: https://github.com/Botts-Innovative-Research/OSHConnect-Python -edit_uri: "" - -docs_dir: docs/markdown -site_dir: docs/build/html - -theme: - name: material - features: - - navigation.sections - - navigation.expand - - navigation.top - - content.code.copy - - toc.follow - palette: - - media: "(prefers-color-scheme: light)" - scheme: default - primary: indigo - accent: indigo - toggle: - icon: material/brightness-7 - name: Switch to dark mode - - media: "(prefers-color-scheme: dark)" - scheme: slate - primary: indigo - accent: indigo - toggle: - icon: material/brightness-4 - name: Switch to light mode - -plugins: - - search - - mkdocstrings: - default_handler: python - handlers: - python: - paths: [src] - options: - show_root_heading: true - show_source: false - show_signature_annotations: true - separate_signature: true - docstring_style: sphinx - members_order: source - filters: ["!^_"] - merge_init_into_class: true - -markdown_extensions: - - admonition - - attr_list - - md_in_html - - toc: - permalink: true - - pymdownx.highlight: - anchor_linenums: true - - pymdownx.inlinehilite - - pymdownx.snippets - - pymdownx.superfences: - custom_fences: - - name: mermaid - class: mermaid - format: !!python/name:pymdownx.superfences.fence_code_format - -nav: - - Home: index.md - - Architecture: architecture.md - - Tutorial: tutorial.md - - API Reference: api.md \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 06ee198..a4acd9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,33 +1,105 @@ [project] name = "oshconnect" -version = "0.5.0a0" +version = "0.5.1a22" description = "Library for interfacing with OSH, helping guide visualization efforts, and providing a place to store configurations. Implements OGC CS API Part 3 (Pub/Sub) MQTT topic conventions including :data topics and resource event topics." readme = "README.md" authors = [ - { name = "Ian Patterson", email = "ian@botts-inc.com" }, + { name = "Ian Patterson", email = "ian.patterson@georobotix.us" }, ] requires-python = "<4.0,>=3.12" dependencies = [ "paho-mqtt>=2.1.0", - "pydantic>=2.12.5,<3.0.0", + "pydantic>=2.13.4,<3.0.0", "shapely>=2.1.2,<3.0.0", - "websockets>=12.0,<16.0", - "requests", - "aiohttp>=3.12.15", + # websockets 16.0 is several majors past the previous floor; OSHConnect + # uses the async client which has been stable across the 13–16 series. + "websockets>=16.0,<17.0", + # Security floors (Dependabot sweep): floors track the latest patched + # release rather than the original advisory baseline, so new installs + # don't drift back to a vulnerable version. + "requests>=2.33.1", + "aiohttp>=3.13.5", + "urllib3>=2.7.0", # transitive via requests; explicit floor pins the patched version ] [project.optional-dependencies] +# Binary encoding extras. The bindings these depend on are generated from the +# SWE Common 3 schemas in https://github.com/tipatterson-dev/BinaryEncodings — +# until that project publishes a pip-installable package, generate the Python +# bindings yourself (``make protobuf PROTO_LANG=python``) and place them on +# PYTHONPATH. See docs/source/tutorial.rst "SWE Protobuf Encoding". +protobuf = ["protobuf>=7.35.0"] +flatbuffers = ["flatbuffers>=24.0"] +# Optional H.264 frame decoding for the Axis-camera demo +# (examples/axis_video_frame.py). PyAV is heavy because it wraps FFmpeg; +# Pillow handles the PNG write step. Both are unused outside the demo +# and only loaded with `try: import` — installing the library without +# this extra works fine and the demo just skips PNG generation. +av = ["av>=15.0.0", "pillow>=11.0.0"] dev = [ - "flake8>=7.2.0", - "pytest>=8.3.5", - "sphinx>=7.4.7", - "sphinx-rtd-theme>=2.0.0", - "mkdocs-material>=9.5.0", - "mkdocstrings[python]>=0.26.0", + "flake8>=7.3.0", + # pytest 9.x is the validated target. The suite uses no APIs that + # PytestRemovedIn9Warning would convert to errors. + "pytest>=9.0.0", + "pytest-cov>=7.0.0", + "interrogate>=1.7.0", + # Sphinx + Furo is the canonical docs toolchain. Furo is the modern + # dark-mode-first theme used by Black, attrs, Pip, etc. Sphinx 9.x + # and myst-parser 5.x are the validated combo; sphinxcontrib-mermaid + # 2.x corresponds to that Sphinx generation. + "sphinx>=9.0.0", + "furo>=2025.12.19", + "myst-parser>=5.0.0", + "sphinxcontrib-mermaid>=2.0.0", + "sphinx-copybutton>=0.5.2", + # Pygments is transitive via sphinx; explicit floor pins the patched version + # to resolve the Dependabot alert flagging older versions. + "Pygments>=2.20.0", ] -tinydb = ["tinydb>=4.8.0,<5.0.0"] +tinydb = ["tinydb>=4.8.2,<5.0.0"] [tool.setuptools] packages = {find = { where = ["src/"]}} [tool.pytest.ini_options] pythonpath = ["src"] +markers = [ + "network: test requires a live OSH server or external network endpoint (skipped by default in CI; see workflow `tests.yaml`).", +] + +# Coverage is opt-in (run with `pytest --cov`) so the default `pytest` run stays fast. +# `--cov` with no argument picks up the source paths configured below. + +[tool.coverage.run] +source = ["src/oshconnect"] +branch = true + +[tool.coverage.report] +show_missing = true +skip_covered = false +precision = 2 +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "if TYPE_CHECKING:", + "@(abc\\.)?abstractmethod", + "if __name__ == .__main__.:", +] + +[tool.coverage.html] +directory = "htmlcov" + +[tool.coverage.xml] +output = "coverage.xml" + +# Docstring presence (not style). Run with `uv run interrogate -v src/oshconnect`. +[tool.interrogate] +ignore-init-method = true # constructors covered by class docstring +ignore-init-module = true # don't require docstrings on bare __init__.py +ignore-magic = true # skip dunder methods (__repr__, __eq__, etc.) +ignore-private = true # skip _name and __name (non-dunder) members +ignore-property-decorators = true +ignore-nested-functions = true +ignore-setters = true +fail-under = 0 # report-only for now; raise once a baseline is set +exclude = ["tests", "docs", "build", ".venv", "scripts"] +verbose = 2 # 0=summary, 1=per-file, 2=per-symbol diff --git a/scripts/publish-local.py b/scripts/publish-local.py index 8639445..de03de7 100755 --- a/scripts/publish-local.py +++ b/scripts/publish-local.py @@ -145,9 +145,9 @@ def main() -> int: print(f" Browse: {PYPI_URL}/simple/") print(f" Install: pip install --index-url {PYPI_URL}/simple/ oshconnect") print(f" uv: uv pip install --index-url {PYPI_URL}/simple/ oshconnect") - print(f" uv sync: uv sync (if pyproject.toml has [[tool.uv.index]] configured)") + print(" uv sync: uv sync (if pyproject.toml has [[tool.uv.index]] configured)") return 0 if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/src/oshconnect/__init__.py b/src/oshconnect/__init__.py index c1a2287..4b677e2 100644 --- a/src/oshconnect/__init__.py +++ b/src/oshconnect/__init__.py @@ -33,7 +33,34 @@ QuantityRangeSchema, TimeRangeSchema, ) -from .schema_datamodels import SWEDatastreamRecordSchema, JSONDatastreamRecordSchema, JSONCommandSchema +from .schema_datamodels import ( + SWEDatastreamRecordSchema, + SWEBinaryDatastreamRecordSchema, + SWEProtobufDatastreamRecordSchema, + SWEFlatBuffersDatastreamRecordSchema, + OMJSONDatastreamRecordSchema, + SWEJSONCommandSchema, + JSONCommandSchema, + AnyDatastreamRecordSchema, + AnyCommandSchema, +) +from .encoding import ( + Encoding, + JSONEncoding, + BinaryEncoding, + BinaryComponentMember, + BinaryBlockMember, + ProtobufEncoding, + FlatBuffersEncoding, +) +from .swe_binary import SWEBinaryCodec +from .swe_flatbuffers import SWEFlatBuffersCodec +# swe_protobuf is import-guarded — exposing the codec class re-exports the +# `_INSTALL_HINT` error so callers learn what to install when invoking it. +from .swe_protobuf import SWEProtobufCodec + +# SensorML structured fields (carried by SystemResource) +from .sensorml import Term, Characteristics, Capabilities # Event system from .events import EventHandler, IEventListener, CallbackListener, DefaultEventTypes, AtomicEventTypes, Event, EventBuilder @@ -76,8 +103,29 @@ "QuantityRangeSchema", "TimeRangeSchema", "SWEDatastreamRecordSchema", - "JSONDatastreamRecordSchema", + "SWEBinaryDatastreamRecordSchema", + "SWEProtobufDatastreamRecordSchema", + "SWEFlatBuffersDatastreamRecordSchema", + "OMJSONDatastreamRecordSchema", + "SWEJSONCommandSchema", "JSONCommandSchema", + "AnyDatastreamRecordSchema", + "AnyCommandSchema", + # Encodings + binary codecs + "Encoding", + "JSONEncoding", + "BinaryEncoding", + "BinaryComponentMember", + "BinaryBlockMember", + "ProtobufEncoding", + "FlatBuffersEncoding", + "SWEBinaryCodec", + "SWEProtobufCodec", + "SWEFlatBuffersCodec", + # SensorML structured fields + "Term", + "Characteristics", + "Capabilities", # Event system "EventHandler", "IEventListener", diff --git a/src/oshconnect/api_helpers.py b/src/oshconnect/api_helpers.py index 87c7d66..2d3615a 100644 --- a/src/oshconnect/api_helpers.py +++ b/src/oshconnect/api_helpers.py @@ -6,15 +6,14 @@ # ============================================================================= from typing import Union -import requests from pydantic import HttpUrl -from csapi4py.con_sys_api import ConnectedSystemsRequestBuilder -from csapi4py.constants import APITerms -from csapi4py.request_wrappers import post_request +from .csapi4py.con_sys_api import ConnectedSystemsRequestBuilder +from .csapi4py.constants import APITerms -def get_landing_page(server_addr: HttpUrl, api_root: str = APITerms.API.value): +def get_landing_page(server_addr: HttpUrl, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Returns the landing page of the API :return: @@ -23,11 +22,15 @@ def get_landing_page(server_addr: HttpUrl, api_root: str = APITerms.API.value): api_request = (builder.with_server_url(server_addr) .with_api_root(api_root) .build_url_from_base() + .with_headers(headers) + .with_basic_auth(auth) + .with_request_method('GET') .build()) - return api_request + return api_request.make_request() -def get_conformance_info(server_addr: HttpUrl, api_root: str = APITerms.API.value): +def get_conformance_info(server_addr: HttpUrl, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Returns the conformance information of the API :return: @@ -37,11 +40,15 @@ def get_conformance_info(server_addr: HttpUrl, api_root: str = APITerms.API.valu .with_api_root(api_root) .for_resource_type(APITerms.CONFORMANCE.value) .build_url_from_base() + .with_headers(headers) + .with_basic_auth(auth) + .with_request_method('GET') .build()) - return api_request + return api_request.make_request() -def list_all_collections(server_addr: HttpUrl, api_root: str = APITerms.API.value): +def list_all_collections(server_addr: HttpUrl, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ List all collections :return: @@ -51,11 +58,15 @@ def list_all_collections(server_addr: HttpUrl, api_root: str = APITerms.API.valu .with_api_root(api_root) .for_resource_type(APITerms.COLLECTIONS.value) .build_url_from_base() + .with_headers(headers) + .with_basic_auth(auth) + .with_request_method('GET') .build()) - return api_request + return api_request.make_request() -def retrieve_collection_metadata(server_addr: HttpUrl, collection_id: str, api_root: str = APITerms.API.value): +def retrieve_collection_metadata(server_addr: HttpUrl, collection_id: str, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Retrieve a collection by its ID :return: @@ -66,11 +77,15 @@ def retrieve_collection_metadata(server_addr: HttpUrl, collection_id: str, api_r .for_resource_type(APITerms.COLLECTIONS.value) .with_resource_id(collection_id) .build_url_from_base() + .with_headers(headers) + .with_basic_auth(auth) + .with_request_method('GET') .build()) - return api_request + return api_request.make_request() -def list_all_items_in_collection(server_addr: HttpUrl, collection_id: str, api_root: str = APITerms.API.value): +def list_all_items_in_collection(server_addr: HttpUrl, collection_id: str, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Lists all systems in the server at the default API endpoint :return: @@ -82,12 +97,16 @@ def list_all_items_in_collection(server_addr: HttpUrl, collection_id: str, api_r .with_resource_id(collection_id) .for_sub_resource_type(APITerms.ITEMS.value) .build_url_from_base() + .with_headers(headers) + .with_basic_auth(auth) + .with_request_method('GET') .build()) - return api_request + return api_request.make_request() def retrieve_collection_item_by_id(server_addr: HttpUrl, collection_id: str, item_id: str, - api_root: str = APITerms.API.value): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Retrieves a system by its id :return: @@ -100,11 +119,15 @@ def retrieve_collection_item_by_id(server_addr: HttpUrl, collection_id: str, ite .for_sub_resource_type(APITerms.ITEMS.value) .with_resource_id(item_id) .build_url_from_base() + .with_headers(headers) + .with_basic_auth(auth) + .with_request_method('GET') .build()) - return api_request + return api_request.make_request() -def list_all_commands(server_addr: HttpUrl, api_root: str = APITerms.API.value, headers: dict = None): +def list_all_commands(server_addr: HttpUrl, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Lists all commands :return: @@ -115,6 +138,7 @@ def list_all_commands(server_addr: HttpUrl, api_root: str = APITerms.API.value, .for_resource_type(APITerms.COMMANDS.value) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) @@ -122,7 +146,7 @@ def list_all_commands(server_addr: HttpUrl, api_root: str = APITerms.API.value, def list_commands_of_control_channel(server_addr: HttpUrl, control_channel_id: str, api_root: str = APITerms.API.value, - headers=None): + auth: tuple = None, headers: dict = None): """ Lists all commands of a control channel :return: @@ -135,6 +159,7 @@ def list_commands_of_control_channel(server_addr: HttpUrl, control_channel_id: s .for_sub_resource_type(APITerms.COMMANDS.value) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) @@ -143,7 +168,8 @@ def list_commands_of_control_channel(server_addr: HttpUrl, control_channel_id: s def send_commands_to_specific_control_stream(server_addr: HttpUrl, control_stream_id: str, request_body: Union[dict, str], - api_root: str = APITerms.API.value, headers=None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Sends a command to a control stream by its id :return: @@ -157,13 +183,15 @@ def send_commands_to_specific_control_stream(server_addr: HttpUrl, control_strea .with_request_body(request_body) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('POST') .build()) return api_request.make_request() -def retrieve_command_by_id(server_addr: HttpUrl, command_id: str, api_root: str = APITerms.API.value, headers=None): +def retrieve_command_by_id(server_addr: HttpUrl, command_id: str, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Retrieves a command by its id :return: @@ -175,6 +203,7 @@ def retrieve_command_by_id(server_addr: HttpUrl, command_id: str, api_root: str .with_resource_id(command_id) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) @@ -182,7 +211,8 @@ def retrieve_command_by_id(server_addr: HttpUrl, command_id: str, api_root: str def update_command_description(server_addr: HttpUrl, command_id: str, request_body: Union[dict, str], - api_root: str = APITerms.API.value, headers=None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Updates a command's description by its id :return: @@ -195,13 +225,15 @@ def update_command_description(server_addr: HttpUrl, command_id: str, request_bo .with_request_body(request_body) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('PUT') .build()) return api_request.make_request() -def delete_command_by_id(server_addr: HttpUrl, command_id: str, api_root: str = APITerms.API.value, headers=None): +def delete_command_by_id(server_addr: HttpUrl, command_id: str, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Deletes a command by its id :return: @@ -213,6 +245,7 @@ def delete_command_by_id(server_addr: HttpUrl, command_id: str, api_root: str = .with_resource_id(command_id) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('DELETE') .build()) @@ -220,7 +253,7 @@ def delete_command_by_id(server_addr: HttpUrl, command_id: str, api_root: str = def list_command_status_reports(server_addr: HttpUrl, command_id: str, api_root: str = APITerms.API.value, - headers=None): + auth: tuple = None, headers: dict = None): """ Lists all status reports of a command by its id :return: @@ -233,6 +266,7 @@ def list_command_status_reports(server_addr: HttpUrl, command_id: str, api_root: .for_sub_resource_type(APITerms.STATUS.value) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) @@ -240,7 +274,8 @@ def list_command_status_reports(server_addr: HttpUrl, command_id: str, api_root: def add_command_status_reports(server_addr: HttpUrl, command_id: str, request_body: Union[dict, str], - api_root: str = APITerms.API.value, headers=None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Adds a status report to a command by its id :return: @@ -254,6 +289,7 @@ def add_command_status_reports(server_addr: HttpUrl, command_id: str, request_bo .with_request_body(request_body) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('POST') .build()) @@ -261,7 +297,8 @@ def add_command_status_reports(server_addr: HttpUrl, command_id: str, request_bo def retrieve_command_status_report_by_id(server_addr: HttpUrl, command_id: str, status_report_id: str, - api_root: str = APITerms.API.value, headers=None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Retrieves a status report of a command by its id and status report id :return: @@ -275,6 +312,7 @@ def retrieve_command_status_report_by_id(server_addr: HttpUrl, command_id: str, .with_secondary_resource_id(status_report_id) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) @@ -283,7 +321,7 @@ def retrieve_command_status_report_by_id(server_addr: HttpUrl, command_id: str, def update_command_status_report_by_id(server_addr: HttpUrl, command_id: str, status_report_id: str, request_body: Union[dict, str], api_root: str = APITerms.API.value, - headers=None): + auth: tuple = None, headers: dict = None): """ Updates a status report of a command by its id and status report id :return: @@ -298,6 +336,7 @@ def update_command_status_report_by_id(server_addr: HttpUrl, command_id: str, st .with_request_body(request_body) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('PUT') .build()) @@ -305,7 +344,8 @@ def update_command_status_report_by_id(server_addr: HttpUrl, command_id: str, st def delete_command_status_report_by_id(server_addr: HttpUrl, command_id: str, status_report_id: str, - api_root: str = APITerms.API.value, headers=None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Deletes a status report of a command by its id and status report id :return: @@ -319,13 +359,15 @@ def delete_command_status_report_by_id(server_addr: HttpUrl, command_id: str, st .with_secondary_resource_id(status_report_id) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('DELETE') .build()) return api_request.make_request() -def list_all_control_streams(server_addr: HttpUrl, api_root: str = APITerms.API.value, headers: dict = None): +def list_all_control_streams(server_addr: HttpUrl, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Lists all control streams :return: @@ -336,13 +378,14 @@ def list_all_control_streams(server_addr: HttpUrl, api_root: str = APITerms.API. .for_resource_type(APITerms.CONTROL_STREAMS.value) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) return api_request.make_request() def list_control_streams_of_system(server_addr: HttpUrl, system_id: str, api_root: str = APITerms.API.value, - headers=None): + auth: tuple = None, headers: dict = None): """ Lists all control streams of a system :return: @@ -355,13 +398,15 @@ def list_control_streams_of_system(server_addr: HttpUrl, system_id: str, api_roo .for_sub_resource_type(APITerms.CONTROL_STREAMS.value) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) return api_request.make_request() def add_control_streams_to_system(server_addr: HttpUrl, system_id: str, request_body: Union[str, dict], - api_root: str = APITerms.API.value, headers=None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Adds a control stream to a system by its id :return: @@ -375,13 +420,15 @@ def add_control_streams_to_system(server_addr: HttpUrl, system_id: str, request_ .with_request_body(request_body) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('POST') .build()) return api_request.make_request() def retrieve_control_stream_description_by_id(server_addr: HttpUrl, control_stream_id: str, - api_root: str = APITerms.API.value, headers: dict = None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Retrieves a control stream by its id :return: @@ -393,6 +440,7 @@ def retrieve_control_stream_description_by_id(server_addr: HttpUrl, control_stre .with_resource_id(control_stream_id) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) @@ -401,7 +449,8 @@ def retrieve_control_stream_description_by_id(server_addr: HttpUrl, control_stre def update_control_stream_description_by_id(server_addr: HttpUrl, control_stream_id: str, request_body: Union[str, dict], - api_root: str = APITerms.API.value, headers: dict = None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Updates a control stream by its id :return: @@ -414,13 +463,14 @@ def update_control_stream_description_by_id(server_addr: HttpUrl, control_stream .with_request_body(request_body) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('PUT') .build()) return api_request.make_request() def delete_control_stream_by_id(server_addr: HttpUrl, control_stream_id: str, api_root: str = APITerms.API.value, - headers=None): + auth: tuple = None, headers: dict = None): """ Deletes a control stream by its id :return: @@ -432,6 +482,7 @@ def delete_control_stream_by_id(server_addr: HttpUrl, control_stream_id: str, ap .with_resource_id(control_stream_id) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('DELETE') .build()) @@ -439,7 +490,8 @@ def delete_control_stream_by_id(server_addr: HttpUrl, control_stream_id: str, ap def retrieve_control_stream_schema_by_id(server_addr: HttpUrl, control_stream_id: str, - api_root: str = APITerms.API.value, headers: dict = None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Retrieves a control stream schema by its id :return: @@ -452,6 +504,7 @@ def retrieve_control_stream_schema_by_id(server_addr: HttpUrl, control_stream_id .for_sub_resource_type(APITerms.SCHEMA.value) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) @@ -459,7 +512,8 @@ def retrieve_control_stream_schema_by_id(server_addr: HttpUrl, control_stream_id def update_control_stream_schema_by_id(server_addr: HttpUrl, control_stream_id: str, request_body: Union[str, dict], - api_root: str = APITerms.API.value, headers: dict = None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Updates a control stream schema by its id :return: @@ -469,16 +523,17 @@ def update_control_stream_schema_by_id(server_addr: HttpUrl, control_stream_id: .with_api_root(api_root) .for_resource_type(APITerms.CONTROL_STREAMS.value) .with_resource_id(control_stream_id) - # .for_sub_resource_type(APITerms.SCHEMA.value) .with_request_body(request_body) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('PUT') .build()) return api_request.make_request() -def list_all_datastreams(server_addr: HttpUrl, api_root: str = APITerms.API.value, headers: dict = None): +def list_all_datastreams(server_addr: HttpUrl, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Lists all datastreams :return: @@ -489,6 +544,7 @@ def list_all_datastreams(server_addr: HttpUrl, api_root: str = APITerms.API.valu .for_resource_type(APITerms.DATASTREAMS.value) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) @@ -496,7 +552,7 @@ def list_all_datastreams(server_addr: HttpUrl, api_root: str = APITerms.API.valu def list_all_datastreams_of_system(server_addr: HttpUrl, system_id: str, api_root: str = APITerms.API.value, - headers=None): + auth: tuple = None, headers: dict = None): """ Lists all datastreams of a system :return: @@ -509,13 +565,15 @@ def list_all_datastreams_of_system(server_addr: HttpUrl, system_id: str, api_roo .for_sub_resource_type(APITerms.DATASTREAMS.value) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) return api_request.make_request() def add_datastreams_to_system(server_addr: HttpUrl, system_id: str, request_body: Union[str, dict], - api_root: str = APITerms.API.value, headers=None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Adds a datastream to a system by its id :return: @@ -529,13 +587,14 @@ def add_datastreams_to_system(server_addr: HttpUrl, system_id: str, request_body .with_request_body(request_body) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('POST') .build()) return api_request.make_request() def retrieve_datastream_by_id(server_addr: HttpUrl, datastream_id: str, api_root: str = APITerms.API.value, - headers=None): + auth: tuple = None, headers: dict = None): """ Retrieves a datastream by its id :return: @@ -547,13 +606,15 @@ def retrieve_datastream_by_id(server_addr: HttpUrl, datastream_id: str, api_root .with_resource_id(datastream_id) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) return api_request.make_request() def update_datastream_by_id(server_addr: HttpUrl, datastream_id: str, request_body: Union[str, dict], - api_root: str = APITerms.API.value, headers=None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Updates a datastream by its id :return: @@ -566,12 +627,14 @@ def update_datastream_by_id(server_addr: HttpUrl, datastream_id: str, request_bo .with_request_body(request_body) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('PUT') .build()) return api_request.make_request() -def delete_datastream_by_id(server_addr: HttpUrl, datastream_id: str, api_root: str = APITerms.API.value, headers=None): +def delete_datastream_by_id(server_addr: HttpUrl, datastream_id: str, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Deletes a datastream by its id :return: @@ -583,16 +646,30 @@ def delete_datastream_by_id(server_addr: HttpUrl, datastream_id: str, api_root: .with_resource_id(datastream_id) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('DELETE') .build()) return api_request.make_request() def retrieve_datastream_schema(server_addr: HttpUrl, datastream_id: str, api_root: str = APITerms.API.value, - headers=None): + auth: tuple = None, headers: dict = None, obs_format: str = None): """ - Retrieves a datastream schema by its id - :return: + Retrieves a datastream schema by its id. + + Hits ``GET /datastreams/{datastream_id}/schema``, optionally with + ``?obsFormat={obs_format}`` to pick a specific schema variant. The + CS API supports ``application/swe+json`` (default for typed + record schemas) and ``application/om+json`` (observation-model + form); OSH additionally supports ``logical`` (a JSON Schema + document with ``x-ogc-*`` extension keywords — OSH-specific, not + in the spec). + + Returns the raw HTTP response. Parse the body with the + appropriate schema model from ``oshconnect.schema_datamodels``: + ``SWEDatastreamRecordSchema.from_swejson_dict``, + ``OMJSONDatastreamRecordSchema.from_omjson_dict``, or + ``LogicalDatastreamRecordSchema.from_logical_dict``. """ builder = ConnectedSystemsRequestBuilder() api_request = (builder.with_server_url(server_addr) @@ -602,13 +679,17 @@ def retrieve_datastream_schema(server_addr: HttpUrl, datastream_id: str, api_roo .for_sub_resource_type(APITerms.SCHEMA.value) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) + if obs_format is not None: + api_request.params = {'obsFormat': obs_format} return api_request.make_request() def update_datastream_schema(server_addr: HttpUrl, datastream_id: str, request_body: dict, - api_root: str = APITerms.API.value, headers=None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Updates a datastream schema by its id :return: @@ -622,12 +703,14 @@ def update_datastream_schema(server_addr: HttpUrl, datastream_id: str, request_b .with_request_body(request_body) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('PUT') .build()) return api_request.make_request() -def list_all_deployments(server_addr: HttpUrl, api_root: str = APITerms.API.value, headers: dict = None): +def list_all_deployments(server_addr: HttpUrl, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Lists all deployments in the server at the default API endpoint :return: @@ -638,13 +721,14 @@ def list_all_deployments(server_addr: HttpUrl, api_root: str = APITerms.API.valu .for_resource_type(APITerms.DEPLOYMENTS.value) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) return api_request.make_request() def create_new_deployments(server_addr: HttpUrl, request_body: Union[str, dict], api_root: str = APITerms.API.value, - headers: dict = None): + auth: tuple = None, headers: dict = None): """ Create a new deployment as defined by the request body :return: @@ -656,13 +740,14 @@ def create_new_deployments(server_addr: HttpUrl, request_body: Union[str, dict], .with_request_body(request_body) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('POST') .build()) return api_request.make_request() def retrieve_deployment_by_id(server_addr: HttpUrl, deployment_id: str, api_root: str = APITerms.API.value, - headers: dict = None): + auth: tuple = None, headers: dict = None): """ Retrieve a deployment by its ID :return: @@ -674,13 +759,15 @@ def retrieve_deployment_by_id(server_addr: HttpUrl, deployment_id: str, api_root .with_resource_id(deployment_id) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) return api_request.make_request() def update_deployment_by_id(server_addr: HttpUrl, deployment_id: str, request_body: Union[str, dict], - api_root: str = APITerms.API.value, headers: dict = None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Update a deployment by its ID :return: @@ -693,13 +780,14 @@ def update_deployment_by_id(server_addr: HttpUrl, deployment_id: str, request_bo .with_request_body(request_body) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('PUT') .build()) return api_request.make_request() def delete_deployment_by_id(server_addr: HttpUrl, deployment_id: str, api_root: str = APITerms.API.value, - headers: dict = None): + auth: tuple = None, headers: dict = None): """ Delete a deployment by its ID :return: @@ -711,13 +799,14 @@ def delete_deployment_by_id(server_addr: HttpUrl, deployment_id: str, api_root: .with_resource_id(deployment_id) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('DELETE') .build()) - return api_request + return api_request.make_request() def list_deployed_systems(server_addr: HttpUrl, deployment_id, api_root: str = APITerms.API.value, - headers: dict = None): + auth: tuple = None, headers: dict = None): """ Lists all deployed systems in the server at the default API endpoint :return: @@ -730,13 +819,15 @@ def list_deployed_systems(server_addr: HttpUrl, deployment_id, api_root: str = A .for_sub_resource_type(APITerms.SYSTEMS.value) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) return api_request.make_request() def add_systems_to_deployment(server_addr: HttpUrl, deployment_id: str, uri_list: str, - api_root: str = APITerms.API.value, headers: dict = None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Lists all systems in the server at the default API endpoint :return: @@ -750,19 +841,19 @@ def add_systems_to_deployment(server_addr: HttpUrl, deployment_id: str, uri_list .with_request_body(uri_list) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('POST') .build()) return api_request.make_request() def retrieve_deployed_system_by_id(server_addr: HttpUrl, deployment_id: str, system_id: str, - api_root: str = APITerms.API.value, headers: dict = None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Retrieves a system by its id :return: """ - - # TODO: Add a way to have a secondary resource ID for certain endpoints builder = ConnectedSystemsRequestBuilder() api_request = (builder.with_server_url(server_addr) .with_api_root(api_root) @@ -772,13 +863,15 @@ def retrieve_deployed_system_by_id(server_addr: HttpUrl, deployment_id: str, sys .with_secondary_resource_id(system_id) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) return api_request.make_request() def update_deployed_system_by_id(server_addr: HttpUrl, deployment_id: str, system_id: str, request_body: dict, - api_root: str = APITerms.API.value, headers: dict = None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Update a system by its ID :return: @@ -793,14 +886,16 @@ def update_deployed_system_by_id(server_addr: HttpUrl, deployment_id: str, syste .with_request_body(request_body) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('PUT') .build()) - return api_request + return api_request.make_request() def delete_deployed_system_by_id(server_addr: HttpUrl, deployment_id: str, system_id: str, - api_root: str = APITerms.API.value, headers: dict = None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Delete a system by its ID :return: @@ -814,13 +909,14 @@ def delete_deployed_system_by_id(server_addr: HttpUrl, deployment_id: str, syste .with_secondary_resource_id(system_id) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('DELETE') .build()) return api_request.make_request() def list_deployments_of_specific_system(server_addr: HttpUrl, system_id: str, api_root: str = APITerms.API.value, - headers: dict = None): + auth: tuple = None, headers: dict = None): """ Lists all deployments of a specific system in the server at the default API endpoint :return: @@ -833,12 +929,14 @@ def list_deployments_of_specific_system(server_addr: HttpUrl, system_id: str, ap .for_sub_resource_type(APITerms.DEPLOYMENTS.value) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) return api_request.make_request() -def list_all_observations(server_addr: HttpUrl, api_root: str = APITerms.API.value, headers=None): +def list_all_observations(server_addr: HttpUrl, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Lists all observations :return: @@ -849,6 +947,7 @@ def list_all_observations(server_addr: HttpUrl, api_root: str = APITerms.API.val .for_resource_type(APITerms.OBSERVATIONS.value) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) @@ -856,7 +955,7 @@ def list_all_observations(server_addr: HttpUrl, api_root: str = APITerms.API.val def list_observations_from_datastream(server_addr: HttpUrl, datastream_id: str, api_root: str = APITerms.API.value, - headers=None): + auth: tuple = None, headers: dict = None): """ Lists all observations of a datastream :return: @@ -869,6 +968,7 @@ def list_observations_from_datastream(server_addr: HttpUrl, datastream_id: str, .for_sub_resource_type(APITerms.OBSERVATIONS.value) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) @@ -876,7 +976,8 @@ def list_observations_from_datastream(server_addr: HttpUrl, datastream_id: str, def add_observations_to_datastream(server_addr: HttpUrl, datastream_id: str, request_body: Union[str, dict], - api_root: str = APITerms.API.value, headers=None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Adds an observation to a datastream by its id :return: @@ -890,6 +991,7 @@ def add_observations_to_datastream(server_addr: HttpUrl, datastream_id: str, req .with_request_body(request_body) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('POST') .build()) @@ -897,7 +999,7 @@ def add_observations_to_datastream(server_addr: HttpUrl, datastream_id: str, req def retrieve_observation_by_id(server_addr: HttpUrl, observation_id: str, api_root: str = APITerms.API.value, - headers=None): + auth: tuple = None, headers: dict = None): """ Retrieves an observation by its id :return: @@ -909,6 +1011,7 @@ def retrieve_observation_by_id(server_addr: HttpUrl, observation_id: str, api_ro .with_resource_id(observation_id) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) @@ -916,7 +1019,8 @@ def retrieve_observation_by_id(server_addr: HttpUrl, observation_id: str, api_ro def update_observation_by_id(server_addr: HttpUrl, observation_id: str, request_body: Union[str, dict], - api_root: str = APITerms.API.value, headers=None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Updates an observation by its id :return: @@ -929,6 +1033,7 @@ def update_observation_by_id(server_addr: HttpUrl, observation_id: str, request_ .with_request_body(request_body) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('PUT') .build()) @@ -936,7 +1041,7 @@ def update_observation_by_id(server_addr: HttpUrl, observation_id: str, request_ def delete_observation_by_id(server_addr: HttpUrl, observation_id: str, api_root: str = APITerms.API.value, - headers=None): + auth: tuple = None, headers: dict = None): """ Deletes an observation by its id :return: @@ -948,13 +1053,15 @@ def delete_observation_by_id(server_addr: HttpUrl, observation_id: str, api_root .with_resource_id(observation_id) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('DELETE') .build()) return api_request.make_request() -def list_all_procedures(server_addr: HttpUrl, api_root: str = APITerms.API.value, headers: dict = None): +def list_all_procedures(server_addr: HttpUrl, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Lists all procedures in the server at the default API endpoint :return: @@ -965,6 +1072,7 @@ def list_all_procedures(server_addr: HttpUrl, api_root: str = APITerms.API.value .for_resource_type(APITerms.PROCEDURES.value) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) @@ -972,7 +1080,7 @@ def list_all_procedures(server_addr: HttpUrl, api_root: str = APITerms.API.value def create_new_procedures(server_addr: HttpUrl, request_body: Union[str, dict], api_root: str = APITerms.API.value, - headers: dict = None): + auth: tuple = None, headers: dict = None): """ Create a new procedure as defined by the request body :return: @@ -984,14 +1092,14 @@ def create_new_procedures(server_addr: HttpUrl, request_body: Union[str, dict], .with_request_body(request_body) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('POST') .build()) - print(api_request) return api_request.make_request() def retrieve_procedure_by_id(server_addr: HttpUrl, procedure_id: str, api_root: str = APITerms.API.value, - headers: dict = None): + auth: tuple = None, headers: dict = None): """ Retrieve a procedure by its ID :return: @@ -1003,13 +1111,15 @@ def retrieve_procedure_by_id(server_addr: HttpUrl, procedure_id: str, api_root: .with_resource_id(procedure_id) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) return api_request.make_request() def update_procedure_by_id(server_addr: HttpUrl, procedure_id: str, request_body: Union[str, dict], - api_root: str = APITerms.API.value, headers: dict = None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Update a procedure by its ID :return: @@ -1022,13 +1132,14 @@ def update_procedure_by_id(server_addr: HttpUrl, procedure_id: str, request_body .with_request_body(request_body) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('PUT') .build()) return api_request.make_request() def delete_procedure_by_id(server_addr: HttpUrl, procedure_id: str, api_root: str = APITerms.API.value, - headers: dict = None): + auth: tuple = None, headers: dict = None): """ Delete a procedure by its ID :return: @@ -1040,12 +1151,14 @@ def delete_procedure_by_id(server_addr: HttpUrl, procedure_id: str, api_root: st .with_resource_id(procedure_id) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('DELETE') .build()) return api_request.make_request() -def list_all_properties(server_addr: HttpUrl, api_root: str = APITerms.API.value): +def list_all_properties(server_addr: HttpUrl, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ List all properties :return: @@ -1055,11 +1168,15 @@ def list_all_properties(server_addr: HttpUrl, api_root: str = APITerms.API.value .with_api_root(api_root) .for_resource_type(APITerms.PROPERTIES.value) .build_url_from_base() + .with_headers(headers) + .with_basic_auth(auth) + .with_request_method('GET') .build()) - return api_request + return api_request.make_request() -def create_new_properties(server_addr: HttpUrl, request_body: dict, api_root: str = APITerms.API.value): +def create_new_properties(server_addr: HttpUrl, request_body: dict, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Create a new property as defined by the request body :return: @@ -1070,11 +1187,15 @@ def create_new_properties(server_addr: HttpUrl, request_body: dict, api_root: st .for_resource_type(APITerms.PROPERTIES.value) .with_request_body(request_body) .build_url_from_base() + .with_headers(headers) + .with_basic_auth(auth) + .with_request_method('POST') .build()) - return api_request + return api_request.make_request() -def retrieve_property_by_id(server_addr: HttpUrl, property_id: str, api_root: str = APITerms.API.value): +def retrieve_property_by_id(server_addr: HttpUrl, property_id: str, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Retrieve a property by its ID :return: @@ -1085,12 +1206,16 @@ def retrieve_property_by_id(server_addr: HttpUrl, property_id: str, api_root: st .for_resource_type(APITerms.PROPERTIES.value) .with_resource_id(property_id) .build_url_from_base() + .with_headers(headers) + .with_basic_auth(auth) + .with_request_method('GET') .build()) - return api_request + return api_request.make_request() def update_property_by_id(server_addr: HttpUrl, property_id: str, request_body: dict, - api_root: str = APITerms.API.value): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Update a property by its ID :return: @@ -1102,11 +1227,15 @@ def update_property_by_id(server_addr: HttpUrl, property_id: str, request_body: .with_resource_id(property_id) .with_request_body(request_body) .build_url_from_base() + .with_headers(headers) + .with_basic_auth(auth) + .with_request_method('PUT') .build()) - return api_request + return api_request.make_request() -def delete_property_by_id(server_addr: HttpUrl, property_id: str, api_root: str = APITerms.API.value): +def delete_property_by_id(server_addr: HttpUrl, property_id: str, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Delete a property by its ID :return: @@ -1117,11 +1246,15 @@ def delete_property_by_id(server_addr: HttpUrl, property_id: str, api_root: str .for_resource_type(APITerms.PROPERTIES.value) .with_resource_id(property_id) .build_url_from_base() + .with_headers(headers) + .with_basic_auth(auth) + .with_request_method('DELETE') .build()) - return api_request + return api_request.make_request() -def list_all_sampling_features(server_addr: HttpUrl, api_root: str = APITerms.API.value, headers=None): +def list_all_sampling_features(server_addr: HttpUrl, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Lists all sampling features in the server at the default API endpoint :return: @@ -1132,13 +1265,14 @@ def list_all_sampling_features(server_addr: HttpUrl, api_root: str = APITerms.AP .for_resource_type(APITerms.SAMPLING_FEATURES.value) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) return api_request.make_request() def list_sampling_features_of_system(server_addr: HttpUrl, system_id: str, api_root: str = APITerms.API.value, - headers=None): + auth: tuple = None, headers: dict = None): """ Lists all sampling features of a system by its id :return: @@ -1151,13 +1285,15 @@ def list_sampling_features_of_system(server_addr: HttpUrl, system_id: str, api_r .for_sub_resource_type(APITerms.SAMPLING_FEATURES.value) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) return api_request.make_request() def create_new_sampling_features(server_addr: HttpUrl, system_id: str, request_body: Union[dict, str], - api_root: str = APITerms.API.value, headers=None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Create a new sampling feature as defined by the request body :return: @@ -1171,13 +1307,14 @@ def create_new_sampling_features(server_addr: HttpUrl, system_id: str, request_b .with_request_body(request_body) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('POST') .build()) return api_request.make_request() def retrieve_sampling_feature_by_id(server_addr: HttpUrl, sampling_feature_id: str, api_root: str = APITerms.API.value, - headers=None): + auth: tuple = None, headers: dict = None): """ Retrieve a sampling feature by its ID :return: @@ -1189,13 +1326,15 @@ def retrieve_sampling_feature_by_id(server_addr: HttpUrl, sampling_feature_id: s .with_resource_id(sampling_feature_id) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) return api_request.make_request() def update_sampling_feature_by_id(server_addr: HttpUrl, sampling_feature_id: str, request_body: Union[dict, str], - api_root: str = APITerms.API.value, headers=None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Update a sampling feature by its ID :return: @@ -1208,13 +1347,14 @@ def update_sampling_feature_by_id(server_addr: HttpUrl, sampling_feature_id: str .with_request_body(request_body) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('PUT') .build()) return api_request.make_request() def delete_sampling_feature_by_id(server_addr: HttpUrl, sampling_feature_id: str, api_root: str = APITerms.API.value, - headers=None): + auth: tuple = None, headers: dict = None): """ Delete a sampling feature by its ID :return: @@ -1226,12 +1366,14 @@ def delete_sampling_feature_by_id(server_addr: HttpUrl, sampling_feature_id: str .with_resource_id(sampling_feature_id) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('DELETE') .build()) return api_request.make_request() -def list_system_events(server_addr: HttpUrl, api_root: str = APITerms.API.value, headers: dict = None): +def list_system_events(server_addr: HttpUrl, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Lists all system events :return: @@ -1242,13 +1384,14 @@ def list_system_events(server_addr: HttpUrl, api_root: str = APITerms.API.value, .for_resource_type(APITerms.SYSTEM_EVENTS.value) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) return api_request.make_request() def list_events_by_system_id(server_addr: HttpUrl, system_id: str, api_root: str = APITerms.API.value, - headers: dict = None): + auth: tuple = None, headers: dict = None): """ Lists all events of a system :return: @@ -1261,13 +1404,15 @@ def list_events_by_system_id(server_addr: HttpUrl, system_id: str, api_root: str .for_sub_resource_type(APITerms.EVENTS.value) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) return api_request.make_request() def add_new_system_events(server_addr: HttpUrl, system_id: str, request_body: dict, - api_root: str = APITerms.API.value, headers: dict = None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Adds a new system event to a system by its id :return: @@ -1281,13 +1426,15 @@ def add_new_system_events(server_addr: HttpUrl, system_id: str, request_body: di .with_request_body(request_body) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('POST') .build()) return api_request.make_request() def retrieve_system_event_by_id(server_addr: HttpUrl, system_id: str, event_id: str, - api_root: str = APITerms.API.value, headers: dict = None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Retrieves a system event by its id :return: @@ -1301,13 +1448,15 @@ def retrieve_system_event_by_id(server_addr: HttpUrl, system_id: str, event_id: .with_secondary_resource_id(event_id) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) return api_request.make_request() def update_system_event_by_id(server_addr: HttpUrl, system_id: str, event_id: str, request_body: dict, - api_root: str = APITerms.API.value, headers: dict = None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Updates a system event by its id :return: @@ -1318,18 +1467,18 @@ def update_system_event_by_id(server_addr: HttpUrl, system_id: str, event_id: st .for_resource_type(APITerms.SYSTEMS.value) .with_resource_id(system_id) .for_sub_resource_type(APITerms.EVENTS.value) - .with_secondary_resource_id(event_id) .with_request_body(request_body) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('PUT') .build()) return api_request.make_request() def delete_system_event_by_id(server_addr: HttpUrl, system_id: str, event_id: str, api_root: str = APITerms.API.value, - headers: dict = None): + auth: tuple = None, headers: dict = None): """ Deletes a system event by its id :return: @@ -1343,13 +1492,15 @@ def delete_system_event_by_id(server_addr: HttpUrl, system_id: str, event_id: st .with_secondary_resource_id(event_id) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('DELETE') .build()) return api_request.make_request() -def list_system_history(server_addr: HttpUrl, system_id: str, api_root: str = APITerms.API.value, headers: dict = None): +def list_system_history(server_addr: HttpUrl, system_id: str, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Lists all history versions of a system :return: @@ -1362,13 +1513,15 @@ def list_system_history(server_addr: HttpUrl, system_id: str, api_root: str = AP .for_resource_type(APITerms.HISTORY.value) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) return api_request.make_request() def retrieve_system_historical_description_by_id(server_addr: HttpUrl, system_id: str, history_id: str, - api_root: str = APITerms.API.value, headers: dict = None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Retrieves a historical system description by its id :return: @@ -1382,13 +1535,15 @@ def retrieve_system_historical_description_by_id(server_addr: HttpUrl, system_id .with_secondary_resource_id(history_id) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) return api_request.make_request() def update_system_historical_description(server_addr: HttpUrl, system_id: str, history_rev_id: str, request_body: dict, - api_root: str = APITerms.API.value, headers: dict = None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Updates a historical system description by its id :return: @@ -1403,13 +1558,15 @@ def update_system_historical_description(server_addr: HttpUrl, system_id: str, h .with_request_body(request_body) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('PUT') .build()) return api_request.make_request() def delete_system_historical_description_by_id(server_addr: HttpUrl, system_id: str, history_rev_id: str, - api_root: str = APITerms.API.value, headers: dict = None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Deletes a historical system description by its id :return: @@ -1423,12 +1580,14 @@ def delete_system_historical_description_by_id(server_addr: HttpUrl, system_id: .with_secondary_resource_id(history_rev_id) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('DELETE') .build()) return api_request.make_request() -def list_all_systems(server_addr: HttpUrl, api_root: str = APITerms.API.value, headers: dict = None): +def list_all_systems(server_addr: HttpUrl, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Lists all systems in the server at the default API endpoint :return: @@ -1439,6 +1598,7 @@ def list_all_systems(server_addr: HttpUrl, api_root: str = APITerms.API.value, h .for_resource_type(APITerms.SYSTEMS.value) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('GET') .build()) @@ -1446,8 +1606,7 @@ def list_all_systems(server_addr: HttpUrl, api_root: str = APITerms.API.value, h def create_new_systems(server_addr: HttpUrl, request_body: Union[str, dict], api_root: str = APITerms.API.value, - uname: str = None, - pword: str = None, headers: dict = None): + auth: tuple = None, headers: dict = None): """ Create a new system as defined by the request body :return: @@ -1458,18 +1617,15 @@ def create_new_systems(server_addr: HttpUrl, request_body: Union[str, dict], api .for_resource_type(APITerms.SYSTEMS.value) .with_request_body(request_body) .build_url_from_base() - .with_auth(uname, pword) .with_headers(headers) + .with_basic_auth(auth) .with_request_method('POST') .build()) - print(api_request.url) - # resp = requests.post(api_request.url, data=api_request.body, headers=api_request.headers, auth=(uname, pword)) - resp = post_request(api_request.url, api_request.body, api_request.headers, api_request.auth) - print(f'Create new system response: {resp}') - return resp + return api_request.make_request() -def list_all_systems_in_collection(server_addr: HttpUrl, collection_id: str, api_root: str = APITerms.API.value): +def list_all_systems_in_collection(server_addr: HttpUrl, collection_id: str, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ NOTE: function may not be able to fully represent a request to the API at this time, as the test server lacks a few elements. @@ -1481,16 +1637,17 @@ def list_all_systems_in_collection(server_addr: HttpUrl, collection_id: str, api .with_api_root(api_root) .for_resource_type(APITerms.COLLECTIONS.value) .with_resource_id(collection_id) - # .for_sub_resource_type(APITerms.ITEMS.value) .build_url_from_base() + .with_headers(headers) + .with_basic_auth(auth) + .with_request_method('GET') .build()) - print(api_request.url) - resp = requests.get(api_request.url, params=api_request.body, headers=api_request.headers) - return resp.json() + return api_request.make_request() def add_systems_to_collection(server_addr: HttpUrl, collection_id: str, uri_list: str, - api_root: str = APITerms.API.value): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Lists all systems in the server at the default API endpoint :return: @@ -1503,12 +1660,15 @@ def add_systems_to_collection(server_addr: HttpUrl, collection_id: str, uri_list .for_sub_resource_type(APITerms.ITEMS.value) .with_request_body(uri_list) .build_url_from_base() + .with_headers(headers) + .with_basic_auth(auth) + .with_request_method('POST') .build()) - resp = requests.post(api_request.url, json=api_request.body, headers=api_request.headers) - return resp.json() + return api_request.make_request() -def retrieve_system_by_id(server_addr: HttpUrl, system_id: str, api_root: str = APITerms.API.value): +def retrieve_system_by_id(server_addr: HttpUrl, system_id: str, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Retrieves a system by its id :return: @@ -1519,13 +1679,16 @@ def retrieve_system_by_id(server_addr: HttpUrl, system_id: str, api_root: str = .for_resource_type(APITerms.SYSTEMS.value) .with_resource_id(system_id) .build_url_from_base() + .with_headers(headers) + .with_basic_auth(auth) + .with_request_method('GET') .build()) - resp = requests.get(api_request.url, params=api_request.body, headers=api_request.headers) - return resp.json() + return api_request.make_request() def update_system_description(server_addr: HttpUrl, system_id: str, request_body: str, - api_root: str = APITerms.API.value, headers: dict = None): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Updates a system's description by its id :return: @@ -1538,12 +1701,14 @@ def update_system_description(server_addr: HttpUrl, system_id: str, request_body .with_request_body(request_body) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) + .with_request_method('PUT') .build()) - resp = requests.put(api_request.url, data=request_body, headers=api_request.headers) - return resp + return api_request.make_request() -def delete_system_by_id(server_addr: HttpUrl, system_id: str, api_root: str = APITerms.API.value, headers: dict = None): +def delete_system_by_id(server_addr: HttpUrl, system_id: str, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Deletes a system by its id :return: @@ -1555,12 +1720,14 @@ def delete_system_by_id(server_addr: HttpUrl, system_id: str, api_root: str = AP .with_resource_id(system_id) .build_url_from_base() .with_headers(headers) + .with_basic_auth(auth) .with_request_method('DELETE') .build()) return api_request.make_request() -def list_system_components(server_addr: HttpUrl, system_id: str, api_root: str = APITerms.API.value): +def list_system_components(server_addr: HttpUrl, system_id: str, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Lists all components of a system by its id :return: @@ -1572,14 +1739,16 @@ def list_system_components(server_addr: HttpUrl, system_id: str, api_root: str = .with_resource_id(system_id) .for_sub_resource_type(APITerms.COMPONENTS.value) .build_url_from_base() + .with_headers(headers) + .with_basic_auth(auth) + .with_request_method('GET') .build()) - print(api_request.url) - resp = requests.get(api_request.url, params=api_request.body, headers=api_request.headers) - return resp.json() + return api_request.make_request() def add_system_components(server_addr: HttpUrl, system_id: str, request_body: dict, - api_root: str = APITerms.API.value): + api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Adds components to a system by its id :return: @@ -1592,12 +1761,15 @@ def add_system_components(server_addr: HttpUrl, system_id: str, request_body: di .for_sub_resource_type(APITerms.COMPONENTS.value) .with_request_body(request_body) .build_url_from_base() + .with_headers(headers) + .with_basic_auth(auth) + .with_request_method('POST') .build()) - resp = requests.post(api_request.url, params=api_request.body, headers=api_request.headers) - return resp.json() + return api_request.make_request() -def list_deployments_of_system(server_addr: HttpUrl, system_id: str, api_root: str = APITerms.API.value): +def list_deployments_of_system(server_addr: HttpUrl, system_id: str, api_root: str = APITerms.API.value, + auth: tuple = None, headers: dict = None): """ Lists all deployments of a system by its id :return: @@ -1609,24 +1781,8 @@ def list_deployments_of_system(server_addr: HttpUrl, system_id: str, api_root: s .with_resource_id(system_id) .for_sub_resource_type(APITerms.DEPLOYMENTS.value) .build_url_from_base() - + .with_headers(headers) + .with_basic_auth(auth) + .with_request_method('GET') .build()) - resp = requests.get(api_request.url, params=api_request.body, headers=api_request.headers) - return resp.json() - -# def list_sampling_features_of_system(server_addr: HttpUrl, system_id: str, api_root: str = APITerms.API.value): -# """ -# Lists all sampling features of a system by its id -# :return: -# """ -# builder = ConnectedSystemsRequestBuilder() -# api_request = (builder.with_server_url(server_addr) -# .with_api_root(api_root) -# .for_resource_type(APITerms.SYSTEMS.value) -# .with_resource_id(system_id) -# .for_sub_resource_type(APITerms.SAMPLING_FEATURES.value) -# .build_url_from_base() -# .build()) -# print(api_request.url) -# resp = requests.get(api_request.url, params=api_request.body, headers=api_request.headers) -# return resp.json() + return api_request.make_request() diff --git a/src/oshconnect/csapi4py/con_sys_api.py b/src/oshconnect/csapi4py/con_sys_api.py index 5fbdfd9..2a98d9f 100644 --- a/src/oshconnect/csapi4py/con_sys_api.py +++ b/src/oshconnect/csapi4py/con_sys_api.py @@ -1,4 +1,4 @@ -from typing import Union +from typing import Optional, Union from pydantic import BaseModel, HttpUrl, Field @@ -6,29 +6,106 @@ from .request_wrappers import post_request, put_request, get_request, delete_request +class APIRequest(BaseModel): + """Base for per-verb request classes. + + Holds the fields every HTTP method shares: ``url`` (required), + ``headers``, ``auth``. Subclasses (`GetRequest`, `PostRequest`, + `PutRequest`, `DeleteRequest`) extend with verb-specific fields — + ``params`` for GET/DELETE, ``body`` for POST/PUT — so the type + system rejects incoherent shapes (e.g. a GET carrying a body) at + construction time instead of silently sending them. + + Subclasses implement ``execute()`` to dispatch through the + matching ``request_wrappers`` function. + """ + url: HttpUrl = Field(...) + headers: Union[dict, None] = Field(None) + auth: Union[tuple, None] = Field(None) + + def execute(self): + raise NotImplementedError("APIRequest subclasses must implement execute().") + + +class GetRequest(APIRequest): + """GET — query parameters only; no body.""" + params: Union[dict, None] = Field(None) + + def execute(self): + return get_request(self.url, self.params, self.headers, self.auth) + + +class PostRequest(APIRequest): + """POST — body, optional. ``dict`` lands in ``json``, ``str`` in ``data``.""" + body: Union[dict, str, None] = Field(None) + + def execute(self): + return post_request(self.url, self.body, self.headers, self.auth) + + +class PutRequest(APIRequest): + """PUT — body, optional. Same body routing as POST.""" + body: Union[dict, str, None] = Field(None) + + def execute(self): + return put_request(self.url, self.body, self.headers, self.auth) + + +class DeleteRequest(APIRequest): + """DELETE — query parameters only. HTTP allows a body but the + project's wrapper doesn't pass one, so we don't model it here.""" + params: Union[dict, None] = Field(None) + + def execute(self): + return delete_request(self.url, self.params, self.headers, self.auth) + + class ConnectedSystemAPIRequest(BaseModel): - url: HttpUrl = Field(None) - body: Union[dict, str] = Field(None) - params: dict = Field(None) + """Legacy single-class request shape used by the fluent + ``ConnectedSystemsRequestBuilder`` and the free helper functions + in ``oshconnect.api_helpers``. New code in ``APIHelper`` uses the + per-verb subclasses above. + """ + url: Union[HttpUrl, None] = Field(None) + body: Union[dict, str, None] = Field(None) + params: Union[dict, None] = Field(None) request_method: str = Field('GET') - headers: dict = Field(None) + headers: Union[dict, None] = Field(None) auth: Union[tuple, None] = Field(None) def make_request(self): + self._validate_for_send() match self.request_method: case 'GET': return get_request(self.url, self.params, self.headers, self.auth) case 'POST': - print(f'POST request: {self}') return post_request(self.url, self.body, self.headers, self.auth) case 'PUT': - print(f'PUT request: {self}') return put_request(self.url, self.body, self.headers, self.auth) case 'DELETE': - print(f'DELETE request: {self}') return delete_request(self.url, self.params, self.headers, self.auth) case _: - raise ValueError('Invalid request method') + raise ValueError(f'Invalid request method: {self.request_method!r}') + + def _validate_for_send(self): + """Final coherence check before dispatch. + + ``url`` may be ``None`` during builder-style construction, but + an unset URL at send time is a programming error. ``GET`` with + a body is well-formed at the HTTP level but most servers ignore + the body — we reject it so the caller doesn't silently send + data that goes nowhere. ``POST``/``PUT`` bodies are optional; + ``DELETE`` with a body is allowed by HTTP and accepted here. + """ + if self.url is None: + raise ValueError( + "ConnectedSystemAPIRequest cannot be sent: 'url' is not set." + ) + if self.request_method == 'GET' and self.body is not None: + raise ValueError( + "GET requests must not carry a body; pass query parameters " + "via 'params' instead." + ) class ConnectedSystemsRequestBuilder(BaseModel): @@ -92,7 +169,16 @@ def with_headers(self, headers: dict = None): return self def with_auth(self, uname: str, pword: str): - self.api_request.auth = (uname, pword) + return self.with_basic_auth((uname, pword) if uname is not None or pword is not None else None) + + def with_basic_auth(self, auth: Optional[tuple]): + """ + Set HTTP Basic Auth credentials as a (username, password) tuple. When ``auth`` is ``None``, + leaves any previously set credentials untouched — no-ops cleanly so callers can pass an + optional auth value through the fluent chain without an ``if`` branch. + """ + if auth is not None: + self.api_request.auth = auth return self def build(self): diff --git a/src/oshconnect/csapi4py/default_api_helpers.py b/src/oshconnect/csapi4py/default_api_helpers.py index f0d7e30..ed4a40e 100644 --- a/src/oshconnect/csapi4py/default_api_helpers.py +++ b/src/oshconnect/csapi4py/default_api_helpers.py @@ -12,8 +12,9 @@ from pydantic import BaseModel, Field -from .con_sys_api import ConnectedSystemAPIRequest +from .con_sys_api import DeleteRequest, GetRequest, PostRequest, PutRequest from .constants import APIResourceTypes, ContentTypes, APITerms +from .mqtt import mqtt_topic_format_token # TODO: rework to make the first resource in the endpoint the primary key for URL construction, currently, the implementation is a bit on the confusing side with what is being generated and why. @@ -122,10 +123,9 @@ def create_resource(self, res_type: APIResourceTypes, json_data: any, parent_res if url_endpoint is None: url = self.resource_url_resolver(res_type, None, parent_res_id, from_collection) else: - url = f'{self.server_url}/{self.api_root}/{url_endpoint}' - api_request = ConnectedSystemAPIRequest(url=url, request_method='POST', auth=self.get_helper_auth(), - body=json_data, headers=req_headers) - return api_request.make_request() + url = f'{self.get_api_root_url()}/{url_endpoint}' + return PostRequest(url=url, body=json_data, headers=req_headers, + auth=self.get_helper_auth()).execute() def retrieve_resource(self, res_type: APIResourceTypes, res_id: str = None, parent_res_id: str = None, from_collection: bool = False, @@ -145,14 +145,14 @@ def retrieve_resource(self, res_type: APIResourceTypes, res_id: str = None, pare if url_endpoint is None: url = self.resource_url_resolver(res_type, res_id, parent_res_id, from_collection) else: - url = f'{self.server_url}/{self.api_root}/{url_endpoint}' - api_request = ConnectedSystemAPIRequest(url=url, request_method='GET', auth=self.get_helper_auth(), - headers=req_headers) - return api_request.make_request() + url = f'{self.get_api_root_url()}/{url_endpoint}' + return GetRequest(url=url, headers=req_headers, + auth=self.get_helper_auth()).execute() def get_resource(self, resource_type: APIResourceTypes, resource_id: str = None, subresource_type: APIResourceTypes = None, - req_headers: dict = None): + req_headers: dict = None, + params: dict = None): """ Helper to get resources by type, specifically by id, and optionally a sub-resource collection of a specified resource. @@ -160,6 +160,7 @@ def get_resource(self, resource_type: APIResourceTypes, resource_id: str = None, :param resource_id: :param subresource_type: :param req_headers: + :param params: Optional query-string parameters (e.g., ``{"obsFormat": "logical"}`` for schema variants). :return: """ if req_headers is None: @@ -169,9 +170,8 @@ def get_resource(self, resource_type: APIResourceTypes, resource_id: str = None, res_id_str = f'/{resource_id}' if resource_id else "" sub_res_type_str = f'/{resource_type_to_endpoint(subresource_type)}' if subresource_type else "" complete_url = f'{base_api_url}/{resource_type_str}{res_id_str}{sub_res_type_str}' - api_request = ConnectedSystemAPIRequest(url=complete_url, request_method='GET', auth=self.get_helper_auth(), - headers=req_headers) - return api_request.make_request() + return GetRequest(url=complete_url, params=params, headers=req_headers, + auth=self.get_helper_auth()).execute() def update_resource(self, res_type: APIResourceTypes, res_id: str, json_data: any, parent_res_id: str = None, from_collection: bool = False, url_endpoint: str = None, req_headers: dict = None): @@ -188,12 +188,11 @@ def update_resource(self, res_type: APIResourceTypes, res_id: str, json_data: an :return: """ if url_endpoint is None: - url = self.resource_url_resolver(res_type, None, parent_res_id, from_collection) + url = self.resource_url_resolver(res_type, res_id, parent_res_id, from_collection) else: - url = f'{self.server_url}/{self.api_root}/{url_endpoint}' - api_request = ConnectedSystemAPIRequest(url=url, request_method='PUT', auth=self.get_helper_auth(), - body=json_data, headers=req_headers) - return api_request.make_request() + url = f'{self.get_api_root_url()}/{url_endpoint}' + return PutRequest(url=url, body=json_data, headers=req_headers, + auth=self.get_helper_auth()).execute() def delete_resource(self, res_type: APIResourceTypes, res_id: str, parent_res_id: str = None, from_collection: bool = False, url_endpoint: str = None, req_headers: dict = None): @@ -209,12 +208,11 @@ def delete_resource(self, res_type: APIResourceTypes, res_id: str, parent_res_id :return: """ if url_endpoint is None: - url = self.resource_url_resolver(res_type, None, parent_res_id, from_collection) + url = self.resource_url_resolver(res_type, res_id, parent_res_id, from_collection) else: - url = f'{self.server_url}/{self.api_root}/{url_endpoint}' - api_request = ConnectedSystemAPIRequest(url=url, request_method='DELETE', auth=self.get_helper_auth(), - headers=req_headers) - return api_request.make_request() + url = f'{self.get_api_root_url()}/{url_endpoint}' + return DeleteRequest(url=url, headers=req_headers, + auth=self.get_helper_auth()).execute() # Helpers def resource_url_resolver(self, subresource_type: APIResourceTypes, subresource_id: str = None, @@ -294,7 +292,7 @@ def set_protocol(self, protocol: str): # TODO: add validity checking for resource type combinations def get_mqtt_topic(self, resource_type, subresource_type, resource_id: str, subresource_id: str = None, - data_topic: bool = True): + data_topic: bool = True, format: str | None = None): """ Returns the MQTT topic for the resource type, does not check for validity of the resource type combination :param resource_type: The API resource type of the resource that comes first in the URL, cannot be None @@ -306,9 +304,15 @@ def get_mqtt_topic(self, resource_type, subresource_type, resource_id: str, subr the given type. :param data_topic: If True (default), appends ':data' to the subresource collection endpoint per CS API Part 3 spec for Resource Data Topics. Set to False for Resource Event Topics (no suffix). + :param format: Optional MIME content-type that selects the ``:data/`` format subtopic per CS API Part 3 + §Resource Data Messages Content Negotiation. ``None`` (default) emits a bare ``:data`` topic so the server's + default format applies. Ignored when ``data_topic=False``. Raises ``ValueError`` for unmapped MIME types — see + :func:`oshconnect.csapi4py.mqtt.mqtt_topic_format_token`. :return: """ data_suffix = ':data' if data_topic else '' + if data_topic and format is not None: + data_suffix = f'{data_suffix}/{mqtt_topic_format_token(format)}' subresource_endpoint = f'/{resource_type_to_endpoint(subresource_type)}' resource_endpoint = "" if resource_type is None else f'/{resource_type_to_endpoint(resource_type)}' resource_ident = "" if resource_id is None else f'/{resource_id}' diff --git a/src/oshconnect/csapi4py/mqtt.py b/src/oshconnect/csapi4py/mqtt.py index 69f2bd0..554d421 100644 --- a/src/oshconnect/csapi4py/mqtt.py +++ b/src/oshconnect/csapi4py/mqtt.py @@ -4,23 +4,64 @@ logger = logging.getLogger(__name__) +# CS API Part 3 Resource Data Topic format subtopic. +# +# Mirrors `FORMAT_SUBTOPICS` in the Java reference: +# sensorhub-service-consys-mqtt/.../ConSysTopicValidator.java +# +# Tokens use '-' instead of '+' because MQTT reserves '+' as a single-level +# wildcard and Kafka disallows '+' in topic names. A topic of the form +# `…:data/` selects the wire format for both subscribe and publish. +MQTT_TOPIC_FORMAT_TOKENS = { + "application/json": "json", + "application/swe+json": "swe-json", + "application/swe+binary": "swe-binary", + "application/swe+csv": "swe-csv", + "application/om+json": "om-json", + "application/sml+json": "sml-json", +} + + +def mqtt_topic_format_token(content_type: str) -> str: + """Return the hyphen-token for a CS API Part 3 ``:data/`` subtopic. + + :param content_type: MIME type string, e.g. ``"application/swe+binary"``. + :raises ValueError: if ``content_type`` is not in + :data:`MQTT_TOPIC_FORMAT_TOKENS`. Callers must register a token for + every format they intend to stream — the server raises + ``InvalidTopicException`` on unknown subtopic tokens. + """ + try: + return MQTT_TOPIC_FORMAT_TOKENS[content_type] + except KeyError: + raise ValueError( + f"No MQTT topic-format token registered for content-type " + f"{content_type!r}. Known content-types: " + f"{sorted(MQTT_TOPIC_FORMAT_TOKENS)}" + ) + + class MQTTCommClient: def __init__(self, url, port=1883, username=None, password=None, path='mqtt', client_id_suffix="", transport='tcp', use_tls=False, reconnect_delay=5): + """Wraps a paho mqtt client to provide a simple interface for + interacting with the mqtt server that is customized for this library. + + :param url: url of the mqtt server + :param port: port the mqtt server is communicating over, default is + 1883 or whichever port the main node is using if in websocket mode + :param username: used if node is requiring authentication to access + this service + :param password: used if node is requiring authentication to access + this service + :param path: used for setting the path when using websockets + (usually sensorhub/mqtt by default) + :param transport: 'tcp' (default) or 'websockets' + :param use_tls: explicitly enable TLS; when False (default), + credentials are sent without TLS + :param reconnect_delay: seconds between automatic reconnect attempts + on disconnect (0 disables) """ - Wraps a paho mqtt client to provide a simple interface for interacting with the mqtt server that is customized - for this library. - - :param url: url of the mqtt server - :param port: port the mqtt server is communicating over, default is 1883 or whichever port the main node is - using if in websocket mode - :param username: used if node is requiring authentication to access this service - :param password: used if node is requiring authentication to access this service - :param path: used for setting the path when using websockets (usually sensorhub/mqtt by default) - :param transport: 'tcp' (default) or 'websockets' - :param use_tls: explicitly enable TLS; when False (default), credentials are sent without TLS - :param reconnect_delay: seconds between automatic reconnect attempts on disconnect (0 disables) - """ self.__url = url self.__port = port self.__path = path diff --git a/src/oshconnect/datastores/sqlite_store.py b/src/oshconnect/datastores/sqlite_store.py index 6062bb8..0787f77 100644 --- a/src/oshconnect/datastores/sqlite_store.py +++ b/src/oshconnect/datastores/sqlite_store.py @@ -30,14 +30,14 @@ class SQLiteDataStore(DataStore): Schema notes ------------ Each resource type is stored as a single JSON blob (the output of its - ``serialize()`` method) alongside a primary-key string ID and any foreign-key - columns needed for filtered lookups. Using blobs means new Pydantic fields - do not require schema migrations. + ``to_storage_dict()`` method) alongside a primary-key string ID and any + foreign-key columns needed for filtered lookups. Using blobs means new + Pydantic fields do not require schema migrations. *Bulk operations* (``save_all`` / ``load_all``) work at the Node level: ``save_all`` persists every resource separately for individual lookups; ``load_all`` reconstructs the full hierarchy from the *nodes* table only - (``Node.deserialize`` handles the embedded systems/streams), avoiding + (``Node.from_storage_dict`` handles the embedded systems/streams), avoiding duplication. """ @@ -87,7 +87,7 @@ def _execute(self, sql: str, params: tuple = ()) -> sqlite3.Cursor: # ------------------------------------------------------------------ def save_node(self, node: Node) -> None: - data = json.dumps(node.serialize()) + data = json.dumps(node.to_storage_dict()) self._execute( "INSERT OR REPLACE INTO nodes (id, data) VALUES (?, ?)", (node.get_id(), data), @@ -102,14 +102,14 @@ def load_node( ).fetchone() if row is None: return None - return Node.deserialize(json.loads(row["data"]), session_manager=session_manager) + return Node.from_storage_dict(json.loads(row["data"]), session_manager=session_manager) def load_all_nodes( self, session_manager: Optional[SessionManager] = None ) -> list[Node]: rows = self._execute("SELECT data FROM nodes").fetchall() return [ - Node.deserialize(json.loads(r["data"]), session_manager=session_manager) + Node.from_storage_dict(json.loads(r["data"]), session_manager=session_manager) for r in rows ] @@ -123,7 +123,7 @@ def delete_node(self, node_id: str) -> None: def save_system(self, system: System, node: Node) -> None: system_id = str(system.get_internal_id()) - data = json.dumps(system.serialize()) + data = json.dumps(system.to_storage_dict()) self._execute( "INSERT OR REPLACE INTO systems (id, node_id, data) VALUES (?, ?, ?)", (system_id, node.get_id(), data), @@ -136,13 +136,13 @@ def load_system(self, system_id: str, node: Node) -> Optional[System]: ).fetchone() if row is None: return None - return System.deserialize(json.loads(row["data"]), node) + return System.from_storage_dict(json.loads(row["data"]), node) def load_systems_for_node(self, node_id: str, node: Node) -> list[System]: rows = self._execute( "SELECT data FROM systems WHERE node_id = ?", (node_id,) ).fetchall() - return [System.deserialize(json.loads(r["data"]), node) for r in rows] + return [System.from_storage_dict(json.loads(r["data"]), node) for r in rows] def delete_system(self, system_id: str) -> None: self._execute("DELETE FROM systems WHERE id = ?", (system_id,)) @@ -155,7 +155,7 @@ def delete_system(self, system_id: str) -> None: def save_datastream(self, datastream: Datastream, node: Node) -> None: ds_id = str(datastream.get_internal_id()) system_id = datastream.get_parent_resource_id() - data = json.dumps(datastream.serialize()) + data = json.dumps(datastream.to_storage_dict()) self._execute( "INSERT OR REPLACE INTO datastreams (id, system_id, node_id, data) VALUES (?, ?, ?, ?)", (ds_id, system_id, node.get_id(), data), @@ -168,13 +168,13 @@ def load_datastream(self, datastream_id: str, node: Node) -> Optional[Datastream ).fetchone() if row is None: return None - return Datastream.deserialize(json.loads(row["data"]), node) + return Datastream.from_storage_dict(json.loads(row["data"]), node) def load_datastreams_for_system(self, system_id: str, node: Node) -> list[Datastream]: rows = self._execute( "SELECT data FROM datastreams WHERE system_id = ?", (system_id,) ).fetchall() - return [Datastream.deserialize(json.loads(r["data"]), node) for r in rows] + return [Datastream.from_storage_dict(json.loads(r["data"]), node) for r in rows] def delete_datastream(self, datastream_id: str) -> None: self._execute("DELETE FROM datastreams WHERE id = ?", (datastream_id,)) @@ -187,7 +187,7 @@ def delete_datastream(self, datastream_id: str) -> None: def save_controlstream(self, controlstream: ControlStream, node: Node) -> None: cs_id = str(controlstream.get_internal_id()) system_id = controlstream.get_parent_resource_id() - data = json.dumps(controlstream.serialize()) + data = json.dumps(controlstream.to_storage_dict()) self._execute( "INSERT OR REPLACE INTO controlstreams (id, system_id, node_id, data) VALUES (?, ?, ?, ?)", (cs_id, system_id, node.get_id(), data), @@ -200,13 +200,13 @@ def load_controlstream(self, controlstream_id: str, node: Node) -> Optional[Cont ).fetchone() if row is None: return None - return ControlStream.deserialize(json.loads(row["data"]), node) + return ControlStream.from_storage_dict(json.loads(row["data"]), node) def load_controlstreams_for_system(self, system_id: str, node: Node) -> list[ControlStream]: rows = self._execute( "SELECT data FROM controlstreams WHERE system_id = ?", (system_id,) ).fetchall() - return [ControlStream.deserialize(json.loads(r["data"]), node) for r in rows] + return [ControlStream.from_storage_dict(json.loads(r["data"]), node) for r in rows] def delete_controlstream(self, controlstream_id: str) -> None: self._execute("DELETE FROM controlstreams WHERE id = ?", (controlstream_id,)) @@ -232,7 +232,7 @@ def load_all( ) -> list[Node]: """Reconstruct the full resource graph from the nodes table. - ``Node.deserialize`` handles the embedded systems/datastreams/ + ``Node.from_storage_dict`` handles the embedded systems/datastreams/ controlstreams hierarchy, so only the *nodes* table is used here. The individual resource tables (systems, datastreams, controlstreams) exist for targeted single-resource lookups and are not consulted here diff --git a/src/oshconnect/encoding.py b/src/oshconnect/encoding.py index c5ac19e..c8610c9 100644 --- a/src/oshconnect/encoding.py +++ b/src/oshconnect/encoding.py @@ -1,4 +1,30 @@ -from pydantic import BaseModel, Field, ConfigDict +# ============================================================================= +# Copyright (c) 2026 Georobotix Innovative Research +# Date: 2026/5/19 +# Author: Ian Patterson +# Contact Email: ian.patterson@georobotix.us +# ============================================================================= + +"""SWE Common encoding models. + +`Encoding` is the base; concrete subclasses (`JSONEncoding`, `BinaryEncoding`) +describe **how** a `recordSchema` is serialized on the wire. They do not +describe the **shape** of the record itself — that's in `swe_components`. + +`BinaryEncoding.members` carry one entry per scalar/block in the record, +referencing a component by JSON-pointer-style path (e.g. ``/time``, +``/img``). Each member is either a `BinaryComponentMember` (a fixed-width +scalar with an OGC data-type URI) or a `BinaryBlockMember` (a +size-prefixed opaque payload, optionally identifying a compression +codec like ``H264`` or ``JPEG``). See ``src/oshconnect/swe_binary.py`` +for the runtime codec that consumes these models. +""" + +from __future__ import annotations + +from typing import Annotated, List, Literal, Union + +from pydantic import BaseModel, ConfigDict, Field class Encoding(BaseModel): @@ -9,4 +35,132 @@ class Encoding(BaseModel): class JSONEncoding(Encoding): + # Kept loosely typed as `str` (matching `Encoding.type`) for backwards + # compatibility — older callers may instantiate with non-canonical + # values (e.g. `"json"`). Tighten with a Literal pin only when this + # class is added to a discriminated union. type: str = "JSONEncoding" + + +# ----------------------------------------------------------------------------- +# SWE BinaryEncoding (CS API Part 2 §16.2.3 / SWE Common 3 §6.4 BinaryEncoding) +# ----------------------------------------------------------------------------- +# +# Wire model for `application/swe+binary`. The Python-side codec lives in +# `oshconnect.swe_binary`; this module only provides the parse/dump models. + + +class BinaryComponentMember(BaseModel): + """A fixed-width scalar member of a `BinaryEncoding`. + + Maps an OGC data-type URI to a struct format character at codec time: + ``http://www.opengis.net/def/dataType/OGC/0/double`` → ``d``, + ``float32`` → ``f``, ``uint32`` → ``I``, etc. See + ``oshconnect.swe_binary.DATATYPE_STRUCT_FMT`` for the full table. + + The ``ref`` is a JSON-pointer-style path into the record schema + (e.g. ``/time`` or ``/pan``) and identifies which scalar field this + member encodes. Members appear in `BinaryEncoding.members` in + serialization order — the codec walks them in that order to encode or + decode a record. + """ + model_config = ConfigDict(populate_by_name=True) + + type: Literal["Component"] = "Component" + ref: str = Field(..., description="Path to the referenced scalar (e.g. '/time').") + data_type: str = Field(..., alias='dataType', + description="OGC data-type URI (e.g. .../dataType/OGC/0/float32).") + + +class BinaryBlockMember(BaseModel): + """A size-prefixed opaque block member of a `BinaryEncoding`. + + On the wire the codec writes a 4-byte big-endian ``uint32`` length + followed by ``length`` raw payload bytes. The payload is **opaque**: + the codec does not interpret it. If ``compression`` is set (e.g. + ``H264``, ``JPEG``) it is metadata for downstream consumers — the + SDK does not demux or decode the codec's frames. Callers receive + the raw bytes and are responsible for any further decoding. + + See ``docs/AXIS_CAMERA_FORMATS.md`` (in the OGC code-sprint demo + repo) for an end-to-end example of an H.264 video datastream. + """ + model_config = ConfigDict(populate_by_name=True) + + type: Literal["Block"] = "Block" + ref: str = Field(..., description="Path to the referenced block field (e.g. '/img').") + compression: str = Field(None, + description="Optional codec hint, e.g. 'H264', 'JPEG'. Opaque to the SDK.") + byte_length: int = Field(None, alias='byteLength', + description="Optional fixed byte length (rare; spec allows it).") + padding_bytes_after: int = Field(None, alias='paddingBytes-after') + padding_bytes_before: int = Field(None, alias='paddingBytes-before') + + +# Discriminated union — pydantic dispatches on the literal `type` field +# (``"Component"`` vs ``"Block"``). Add other member types here (currently +# none are commonly seen on OSH wire payloads). +AnyBinaryMember = Annotated[ + Union[BinaryComponentMember, BinaryBlockMember], + Field(discriminator='type'), +] + + +class ProtobufEncoding(Encoding): + """SWE-side Encoding marker for ``application/swe+proto``. + + Carries no member list — the wire layout is fully described by the + accompanying SWE Common 3 Protobuf schema (a generated ``sweCommon3_pb2`` + module produced from + https://github.com/tipatterson-dev/BinaryEncodings). + The Python-side codec lives in ``oshconnect.swe_protobuf``. + + Why no `members`: unlike SWE BinaryEncoding (which has to declare a wire + layout for opaque-bytes payloads), the Protobuf encoding's wire shape is + a self-describing tag-length-value stream defined by the .proto schema. + There is nothing to declare at the SDK level beyond "use the protobuf + codec." + """ + type: Literal["ProtobufEncoding"] = "ProtobufEncoding" + + +class FlatBuffersEncoding(Encoding): + """SWE-side Encoding marker for ``application/swe+flatbuffers``. + + Mirrors `ProtobufEncoding`. The wire layout is described by the + SWE Common 3 FlatBuffers schema (a generated ``sweCommon3_generated`` + module produced from the BinaryEncodings project). + + .. warning:: + + FlatBuffers Python codegen does not currently support + vectors-of-unions, which the SWE Common 3 BinaryEncoding + schema uses for ``[BinaryMember] (union { BinaryComponent, + BinaryBlock })``. Until ``flatc --python`` adds this support, the + FlatBuffers codec raises `NotImplementedError`; the encoding + declaration is preserved so the rest of the SDK can already + round-trip schemas that name it. See + ``docs/osh_spec_deviations.md`` (flatc-python-vector-of-union). + """ + type: Literal["FlatBuffersEncoding"] = "FlatBuffersEncoding" + + +class BinaryEncoding(Encoding): + """SWE BinaryEncoding — describes the wire layout of a binary record. + + The `members` list mirrors the scalar/block fields of the parent + `recordSchema` in serialization order. ``byte_order`` defaults to + ``bigEndian`` (the form OSH emits and the only form the bundled + codec writes); ``byte_encoding`` defaults to ``raw`` (no base64). + + The bundled codec in ``oshconnect.swe_binary`` honours ``byte_order`` + when packing fixed-width scalars; ``byte_encoding`` other than + ``raw`` is parsed but not currently emitted by the encoder (decoder + raises if it sees ``base64`` — open a ticket if you need it). + """ + type: Literal["BinaryEncoding"] = "BinaryEncoding" + byte_order: Literal["bigEndian", "littleEndian"] = Field( + "bigEndian", alias='byteOrder') + byte_encoding: Literal["raw", "base64"] = Field( + "raw", alias='byteEncoding') + members: List[AnyBinaryMember] = Field(default_factory=list) diff --git a/src/oshconnect/node.py b/src/oshconnect/node.py new file mode 100644 index 0000000..7cba2a6 --- /dev/null +++ b/src/oshconnect/node.py @@ -0,0 +1,448 @@ +# ============================================================================= +# Copyright (c) 2026 Georobotix Innovative Research +# Date: 2026/5/18 +# Author: Ian Patterson +# Contact Email: ian.patterson@georobotix.us +# ============================================================================= + +"""`Node` — one client connection to an OpenSensorHub server. + +A `Node` owns the `APIHelper` that builds and executes HTTP requests, an +optional `MQTTCommClient`, and the list of `System` objects discovered +from or inserted into that server. This module also houses the small +session / endpoint helpers that travel with a node: + +- `Endpoints` — default URL path segments for the server's REST APIs. +- `Utilities` — module-level helper namespace (currently just + base64-encoded Basic-Auth construction). +- `OSHClientSession` — per-node client session owning its registered + streamables' lifecycle. +- `SessionManager` — top-level registry of `OSHClientSession` instances. + +`Node.discover_systems` and `Node.from_storage_dict` reach back into the +`System` wrapper at runtime; those imports are deferred to method bodies +to avoid an import cycle with `oshconnect.resources.system`. +""" +from __future__ import annotations + +import asyncio +import base64 +import uuid +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from .csapi4py.constants import APIResourceTypes +from .csapi4py.default_api_helpers import APIHelper +from .csapi4py.mqtt import MQTTCommClient +from .resource_datamodels import SystemResource + +if TYPE_CHECKING: + from .resources.base import StreamableResource + from .resources.system import System + + +@dataclass(kw_only=True) +class Endpoints: + """Default URL path segments for an OSH server's REST APIs.""" + root: str = "sensorhub" + sos: str = f"{root}/sos" + connected_systems: str = f"{root}/api" + + +class Utilities: + """Module-level helper namespace; intentionally just static methods.""" + + @staticmethod + def convert_auth_to_base64(username: str, password: str) -> str: + """Return ``username:password`` Base64-encoded for HTTP Basic Auth.""" + return base64.b64encode(f"{username}:{password}".encode()).decode() + + +class OSHClientSession: + """One client session against a Node, owning its registered streamables. + + Created by `SessionManager.register_session` and used by `Node` to manage + the lifecycle (start/stop) of every `StreamableResource` attached to that + node. Holds the streamables in a dict keyed by streamable ID. + + :param base_url: Base URL of the OSH server (passed by Node, not used + directly by this class today). + :param verify_ssl: Whether to verify TLS certificates. Default True. + """ + verify_ssl = True + _streamables: dict[str, 'StreamableResource'] = None + + def __init__(self, base_url, *args, verify_ssl=True, **kwargs): + # super().__init__(base_url, *args, **kwargs) + self.verify_ssl = verify_ssl + self._streamables = {} + + def connect_streamables(self): + """Call ``start()`` on every registered streamable.""" + for streamable in self._streamables.values(): + streamable.start() + + def close_streamables(self): + """Call ``stop()`` on every registered streamable.""" + for streamable in self._streamables.values(): + streamable.stop() + + def register_streamable(self, streamable: StreamableResource): + """Track a streamable so its lifecycle is driven by this session.""" + if self._streamables is None: + self._streamables = {} + self._streamables[streamable.get_streamable_id_str()] = streamable + + +class SessionManager: + """Top-level registry for `OSHClientSession` instances, one per Node. + + The application owns one `SessionManager`; passing it to ``Node(...)`` + causes the node to call `register_session` and bind itself to a fresh + `OSHClientSession`. `start_session_streams` / `start_all_streams` are + convenience entry points for booting streams on a single node or all + nodes at once. + + :param session_tokens: Optional dict of session tokens keyed by ID + (reserved for future auth schemes; currently unused). + """ + _session_tokens = None + sessions: dict[str, OSHClientSession] = None + + def __init__(self, session_tokens: dict[str, str] = None): + self._session_tokens = session_tokens + self.sessions = {} + + def register_session(self, session_id, session: OSHClientSession) -> OSHClientSession: + """Store ``session`` under ``session_id`` and return it.""" + self.sessions[session_id] = session + return session + + def unregister_session(self, session_id): + """Remove the session and call ``close()`` on it.""" + session = self.sessions.pop(session_id) + session.close() + + def get_session(self, session_id) -> OSHClientSession | None: + """Return the session for ``session_id`` or ``None`` if unknown.""" + return self.sessions.get(session_id, None) + + def start_session_streams(self, session_id): + """Start every streamable on the session identified by ``session_id``. + + :raises ValueError: if no session is registered for that ID. + """ + session = self.get_session(session_id) + if session is None: + raise ValueError(f"No session found for ID {session_id}") + session.connect_streamables() + + def start_all_streams(self): + """Start every streamable across every registered session.""" + for session in self.sessions.values(): + session.connect_streamables() + + +@dataclass(kw_only=True) +class Node: + """One connection to a single OSH server. + + A `Node` is the unit of "where to talk to". It owns the `APIHelper` that + builds and executes HTTP requests, an optional `MQTTCommClient` for + Pub/Sub, and the list of `System` objects discovered from or inserted + into that server. Most user code creates a `Node` and then either calls + `discover_systems()` or attaches user-built systems via `add_system()`. + + :param protocol: ``"http"`` or ``"https"``. + :param address: Hostname or IP (no scheme). + :param port: HTTP port the server is listening on. + :param username: Optional Basic-Auth username. + :param password: Optional Basic-Auth password. + :param server_root: First path segment of the server URL (default + ``"sensorhub"``). + :param api_root: Second path segment under ``server_root`` + (default ``"api"``). + :param mqtt_topic_root: Override for the MQTT topic root if it diverges + from the HTTP api root (CS API Part 3 § A.1). + :param session_manager: Optional `SessionManager`; if given the node + registers itself and gets a fresh `OSHClientSession`. + :param enable_mqtt: If True, connects an MQTT client to ``address``. + :param mqtt_port: MQTT broker port. Default 1883. + """ + _id: str + protocol: str + address: str + port: int + server_root: str = 'sensorhub' + endpoints: Endpoints + is_secure: bool + _basic_auth: bytes + _api_helper: APIHelper + _systems: list[System] = field(default_factory=list) + _client_session: OSHClientSession + _mqtt_client: MQTTCommClient + _mqtt_port: int = 1883 + + def __init__(self, protocol: str, address: str, port: int, username: str = None, password: str = None, + server_root: str = 'sensorhub', api_root: str = 'api', mqtt_topic_root: str = None, + session_manager: SessionManager = None, enable_mqtt: bool = False, mqtt_port: int = 1883): + self._id = f'node-{uuid.uuid4()}' + self.protocol = protocol + self.address = address + self.server_root = server_root + self.port = port + self.is_secure = username is not None and password is not None + if self.is_secure: + self.add_basicauth(username, password) + self.endpoints = Endpoints() + self._api_helper = APIHelper( + server_url=self.address, protocol=self.protocol, port=self.port, + server_root=self.server_root, api_root=api_root, mqtt_topic_root=mqtt_topic_root, + username=username, password=password, + ) + if self.is_secure: + self._api_helper.user_auth = True + self._systems = [] + # Default to no client session; populated by `register_with_session_manager`. + self._client_session = None + if session_manager is not None: + session_task = self.register_with_session_manager(session_manager) + asyncio.gather(session_task) + + if enable_mqtt: + self._mqtt_port = mqtt_port + self._mqtt_client = MQTTCommClient(url=self.address, port=self._mqtt_port, username=username, + password=password, client_id_suffix=uuid.uuid4().hex, ) + self._mqtt_client.connect() + self._mqtt_client.start() + + def get_id(self) -> str: + """Return the locally-generated node ID (``node-``).""" + return self._id + + def get_address(self) -> str: + """Return the configured server hostname/IP.""" + return self.address + + def get_port(self) -> int: + """Return the configured server port.""" + return self.port + + def get_api_endpoint(self) -> str: + """Return the fully-qualified CS API root URL for this node.""" + return self._api_helper.get_api_root_url() + + def add_basicauth(self, username: str, password: str): + """Attach Basic-Auth credentials and mark the node as secure.""" + if not self.is_secure: + self.is_secure = True + self._basic_auth = base64.b64encode(f"{username}:{password}".encode('utf-8')) + + def get_decoded_auth(self) -> str: + """Return the Base64 Basic-Auth header value as a UTF-8 string.""" + return self._basic_auth.decode('utf-8') + + # def get_basicauth(self): + # return BasicAuth(self._api_helper.username, self._api_helper.password) + + def get_mqtt_client(self) -> MQTTCommClient: + """Return the connected `MQTTCommClient` or ``None`` if MQTT was + not enabled at construction (``enable_mqtt=True``).""" + return getattr(self, '_mqtt_client', None) + + def discover_systems(self) -> list[System] | None: + """GET ``/systems?f=application/sml+json`` and create a `System` for + each entry. + + We pin SML+JSON because the GeoJSON listing variant (OSH's default + when no format is specified) is a summary that drops SensorML + detail — ``identifiers``, ``classifiers``, ``keywords``, + ``characteristics``, ``definition``, ``typeOf``, ``configuration``, + ``contacts``, ``documentation``, ``inputs``/``outputs``/``parameters``, + ``modes``, ``method``, ``featuresOfInterest``. SML+JSON delivers + all of those, which cross-node sync and any caller round-tripping + ``_underlying_resource`` need. + + ``Accept: application/sml+json`` is ignored by the OSH listing + endpoint (still returns GeoJSON), so the format is selected via + the ``?f=`` query parameter — the OGC API standard format + selector. ``SystemResource.model_validate`` parses both shapes, + so the wrapper still copes if a server returns GeoJSON anyway. + + The new systems are appended to this node's internal list and also + returned for convenience. + + :return: List of newly-created `System` objects, or ``None`` if + the HTTP request failed. + """ + # Deferred runtime import: System -> StreamableResource -> Node would + # otherwise close a cycle when this module is first loaded. + from .resources.system import System + result = self._api_helper.get_resource( + APIResourceTypes.SYSTEM, + params={'f': 'application/sml+json'}, + ) + if result.ok: + new_systems = [] + system_objs = result.json()['items'] + for system_json in system_objs: + system = SystemResource.model_validate(system_json, by_alias=True) + # Route through the canonical factory so the parsed + # `SystemResource` is bound to the wrapper via + # `set_system_resource(...)`. The previous manual + # `System(label=..., name=..., urn=..., resource_id=...)` + # call dropped the parsed resource on the floor — + # any caller reaching for `_underlying_resource` + # (deep-copy round-trip, cross-node sync, geometry, + # validTime, properties) saw only a thin shell. + sys_obj = System.from_resource(system, parent_node=self) + self._systems.append(sys_obj) + new_systems.append(sys_obj) + return new_systems + else: + return None + + def get_api_helper(self) -> APIHelper: + """Return the `APIHelper` this node uses for HTTP calls.""" + return self._api_helper + + # System Management + + def add_system(self, system: System, insert_resource: bool = False) -> System: + """Attach a system to this node. + + When ``insert_resource=True``, the system is first POSTed to the + server via ``system.insert_self()`` (which populates its + server-assigned resource id), then attached locally — so the + system enters this node's collection already carrying its real + id. With ``insert_resource=False`` the system is attached + in-memory only; useful when reconstructing state from a + datastore or staging a system before a deferred POST. + + :param system: ``System`` object to attach. + :param insert_resource: Whether to POST the system to the + server before attaching it locally. + :return: The same ``System`` (now parented to this node and + tracked in ``self.systems()``). + """ + if insert_resource: + system.insert_self() + system.set_parent_node(self) + self._systems.append(system) + return system + + def systems(self) -> list[System]: + """Return the list of `System` objects currently attached to this node.""" + return self._systems + + def register_with_session_manager(self, session_manager: SessionManager): + """ + Registers this node with the provided session manager, creating a new client session. + :param session_manager: SessionManager instance + """ + self._client_session = session_manager.register_session(self._id, OSHClientSession( + base_url=self._api_helper.get_base_url())) + + def register_streamable(self, streamable: StreamableResource): + """Register a streamable with this node's session so its lifecycle + is driven by `OSHClientSession.connect_streamables` / + `close_streamables`. + + Soft no-op when no `SessionManager` was attached at construction; + the caller can still drive the streamable manually via + `initialize()` / `start()` / `stop()`. + """ + if self._client_session is None: + return + self._client_session.register_streamable(streamable) + + def get_session(self) -> OSHClientSession: + """Return the `OSHClientSession` bound to this node.""" + return self._client_session + + def to_storage_dict(self) -> dict: + """Return a JSON-safe dict snapshot of this node — connection + params, attached systems / streamables, and any locally-tracked + state — for OSHConnect's persistence layer (see + `OSHConnect.save_config`, `oshconnect.datastores.sqlite_store`). + + Not a CS API server-shaped payload; the dict format is OSHConnect's + own. For a CS API-shaped representation, use the underlying + pydantic resource model's ``model_dump(by_alias=True)``. + """ + data = { + "_id": self._id, + "protocol": self.protocol, + "address": self.address, + "port": self.port, + "server_root": self.server_root, + "api_root": getattr(self._api_helper, "api_root", "api"), + "mqtt_topic_root": getattr(self._api_helper, "mqtt_topic_root", None), + "is_secure": self.is_secure, + "username": getattr(self._api_helper, "username", None), + "password": getattr(self._api_helper, "password", None), + "_systems": [system.to_storage_dict() for system in self._systems] if self._systems is not None else None, + } + data["name"] = getattr(self, "name", None) + data["label"] = getattr(self, "label", None) + data["urn"] = getattr(self, "urn", None) + data["description"] = getattr(self, "description", None) + datastreams = getattr(self, "datastreams", None) + if datastreams is not None: + data["datastreams"] = [ds.to_storage_dict() for ds in datastreams] + else: + data["datastreams"] = None + control_channels = getattr(self, "control_channels", None) + if control_channels is not None: + data["control_channels"] = [cc.to_storage_dict() for cc in control_channels] + else: + data["control_channels"] = None + underlying = getattr(self, "_underlying_resource", None) + if underlying is not None: + dump = getattr(underlying, 'model_dump', None) + if callable(dump): + data["underlying_resource"] = underlying.model_dump(by_alias=True, exclude_none=True, mode='json') + elif hasattr(underlying, 'to_dict'): + data["underlying_resource"] = underlying.to_dict() + else: + data["underlying_resource"] = str(underlying) + else: + data["underlying_resource"] = None + # Remove any 'resource' key if present + data.pop("resource", None) + return data + + @classmethod + def from_storage_dict(cls, data: dict, session_manager: 'SessionManager' = None) -> 'Node': + """Build a `Node` from a dict produced by `to_storage_dict` + (i.e., from OSHConnect's persistence layer, not from a CS API + server response). + + Expects connection params (``protocol``, ``address``, ``port``, + optional ``username``/``password``/``server_root``/``api_root``/ + ``mqtt_topic_root``), an ``_id``, and a ``_systems`` list. + + :param data: Source dict. + :param session_manager: Optional `SessionManager` to register the + rebuilt node with — required if any child `StreamableResource` + in ``_systems`` was originally registered. + """ + # Deferred runtime import: System -> StreamableResource -> Node would + # otherwise close a cycle when this module is first loaded. + from .resources.system import System + node = cls( + protocol=data["protocol"], address=data["address"], port=data["port"], + username=data.get("username"), password=data.get("password"), + server_root=data.get("server_root", "sensorhub"), + api_root=data.get("api_root", "api"), + mqtt_topic_root=data.get("mqtt_topic_root"), + ) + node._id = data["_id"] + node.is_secure = data.get("is_secure", False) + # Register with the session manager before rehydrating child resources, + # because StreamableResource.__init__ calls node.register_streamable(). + if session_manager is not None: + node.register_with_session_manager(session_manager) + node._systems = [System.from_storage_dict(sys, node) for sys in data.get("_systems", [])] if data.get( + "_systems") is not None else [] + return node diff --git a/src/oshconnect/oshconnectapi.py b/src/oshconnect/oshconnectapi.py index a50f802..ce6c8d2 100644 --- a/src/oshconnect/oshconnectapi.py +++ b/src/oshconnect/oshconnectapi.py @@ -99,7 +99,7 @@ def save_config(self): data = {} for node in self._nodes: - node_dict = node.serialize() + node_dict = node.to_storage_dict() data.update({node.get_id(): node_dict}) # write to JSON file @@ -303,9 +303,7 @@ def add_system_to_node(self, system: System, target_node: Node, insert_resource: :return: """ if target_node in self._nodes: - target_node.add_new_system(system) - if insert_resource: - system.insert_self() + target_node.add_system(system, insert_resource=insert_resource) self._systems.append(system) return diff --git a/src/oshconnect/resource_datamodels.py b/src/oshconnect/resource_datamodels.py index 8262b9a..32493d4 100644 --- a/src/oshconnect/resource_datamodels.py +++ b/src/oshconnect/resource_datamodels.py @@ -6,16 +6,21 @@ # ============================================================================== from __future__ import annotations -from typing import List +import json +from typing import List, TYPE_CHECKING -from pydantic import BaseModel, ConfigDict, Field, SerializeAsAny, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator from shapely import Point from .api_utils import Link from .geometry import Geometry -from .schema_datamodels import DatastreamRecordSchema, CommandSchema +from .schema_datamodels import AnyCommandSchema, AnyDatastreamRecordSchema +from .sensorml import Capabilities, Characteristics, Term from .timemanagement import TimeInstant, TimePeriod +if TYPE_CHECKING: + from .swe_components import AnyComponent + class BoundingBox(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) @@ -32,60 +37,14 @@ class BoundingBox(BaseModel): # return self -class SecurityConstraints: - constraints: list - - -class LegalConstraints: - constraints: list - - -class Characteristics: - characteristics: list - - -class Capabilities: - capabilities: list - - -class Contact: - contact: list - - -class Documentation: - documentation: list - - -class HistoryEvent: - history_event: list - - -class ConfigurationSettings: - settings: list - - -class FeatureOfInterest: - feature: list - - -class Input: - input: list - - -class Output: - output: list - - -class Parameter: - parameter: list - - -class Mode: - mode: list - - -class ProcessMethod: - method: list +# SensorML structured fields below (identifiers, characteristics, +# capabilities, contacts, etc.) carry rich SWE Common / SensorML Term +# trees on the wire. They were previously typed against bare-class +# placeholders here, which made every SML+JSON server response fail to +# parse (`dict is not instance of Characteristics`). Until we model +# these properly as pydantic types, we accept them as raw `dict` / +# `list[dict]` so cross-node sync round-trips them losslessly. See +# ROADMAP.md. class BaseResource(BaseModel): @@ -99,7 +58,11 @@ class BaseResource(BaseModel): class SystemResource(BaseModel): - model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) + # `extra='allow'` lets unmodeled SensorML fields (e.g. ``position`` + # in the SML+JSON listing) round-trip through the model rather than + # being silently dropped on parse — important for cross-node sync. + model_config = ConfigDict(arbitrary_types_allowed=True, + populate_by_name=True, extra='allow') feature_type: str = Field(None, alias="type") system_id: str = Field(None, alias="id") @@ -112,25 +75,91 @@ class SystemResource(BaseModel): label: str = Field(None) lang: str = Field(None) keywords: List[str] = Field(None) - identifiers: List[str] = Field(None) - classifiers: List[str] = Field(None) + # SensorML Term objects (`{definition, label, value}`). + identifiers: list[Term] = Field(None) + classifiers: list[Term] = Field(None) valid_time: TimePeriod = Field(None, alias="validTime") - security_constraints: List[SecurityConstraints] = Field(None, alias="securityConstraints") - legal_constraints: List[LegalConstraints] = Field(None, alias="legalConstraints") - characteristics: List[Characteristics] = Field(None) - capabilities: List[Capabilities] = Field(None) - contacts: List[Contact] = Field(None) - documentation: List[Documentation] = Field(None) - history: List[HistoryEvent] = Field(None) + security_constraints: list[dict] = Field(None, alias="securityConstraints") + legal_constraints: list[dict] = Field(None, alias="legalConstraints") + # SensorML CharacteristicList / CapabilityList — each carries inner + # SWE Common components routed via `AnyComponent`'s `type` discriminator. + characteristics: list[Characteristics] = Field(None) + capabilities: list[Capabilities] = Field(None) + contacts: list[dict] = Field(None) + documentation: list[dict] = Field(None) + history: list[dict] = Field(None) definition: str = Field(None) type_of: str = Field(None, alias="typeOf") - configuration: ConfigurationSettings = Field(None) - features_of_interest: List[FeatureOfInterest] = Field(None, alias="featuresOfInterest") - inputs: List[Input] = Field(None) - outputs: List[Output] = Field(None) - parameters: List[Parameter] = Field(None) - modes: List[Mode] = Field(None) - method: ProcessMethod = Field(None) + configuration: dict = Field(None) + features_of_interest: list[dict] = Field(None, alias="featuresOfInterest") + inputs: list[dict] = Field(None) + outputs: list[dict] = Field(None) + parameters: list[dict] = Field(None) + modes: list[dict] = Field(None) + method: dict = Field(None) + + def to_smljson_dict(self) -> dict: + """Render this system as an `application/sml+json` dict (SensorML JSON encoding). + + The ``type`` discriminator (``PhysicalSystem``, + ``PhysicalComponent``, ``SimpleProcess``, ``AggregateProcess``, + etc.) is preserved from ``self.feature_type`` when set — + important for cross-node sync, where the source's SML kind + determines how OSH surfaces ``featureType`` (e.g. ``Sensor`` + vs. ``System``). Defaults to ``"PhysicalSystem"`` only when + ``feature_type`` is unset, so callers building a bare + ``SystemResource`` still get a valid SML body. Does not + mutate ``self``. + """ + dumped = self.model_dump(by_alias=True, exclude_none=True, mode='json') + dumped.setdefault("type", "PhysicalSystem") + return dumped + + def to_smljson(self) -> str: + """JSON-string variant of `to_smljson_dict`.""" + return json.dumps(self.to_smljson_dict()) + + def to_geojson_dict(self) -> dict: + """Render this system as an `application/geo+json` dict. + + The ``type`` field is always set to ``"Feature"`` per the + GeoJSON spec, regardless of ``self.feature_type`` — that's the + whole point of this rendering variant. Does not mutate + ``self``. + """ + dumped = self.model_dump(by_alias=True, exclude_none=True, mode='json') + dumped["type"] = "Feature" + return dumped + + def to_geojson(self) -> str: + """JSON-string variant of `to_geojson_dict`.""" + return json.dumps(self.to_geojson_dict()) + + @classmethod + def from_smljson_dict(cls, data: dict) -> "SystemResource": + """Build a `SystemResource` from an `application/sml+json` dict + (e.g., a CS API server response body for a system in SML form).""" + return cls.model_validate(data, by_alias=True) + + @classmethod + def from_geojson_dict(cls, data: dict) -> "SystemResource": + """Build a `SystemResource` from an `application/geo+json` dict + (e.g., a CS API server response body for a system in GeoJSON form).""" + return cls.model_validate(data, by_alias=True) + + @classmethod + def from_csapi_dict(cls, data: dict) -> "SystemResource": + """Build a `SystemResource` from a CS API system dict, auto-dispatching + on the ``type`` field: ``"PhysicalSystem"`` → SML+JSON path, + ``"Feature"`` → GeoJSON path. Anything else falls through to a + permissive validate. + """ + feature_type = data.get("type") + if feature_type == "PhysicalSystem": + return cls.from_smljson_dict(data) + if feature_type == "Feature": + return cls.from_geojson_dict(data) + return cls.model_validate(data, by_alias=True) class DatastreamResource(BaseModel): @@ -152,12 +181,15 @@ class DatastreamResource(BaseModel): feature_of_interest_link: Link = Field(None, alias="featureOfInterest@link") sampling_feature_link: Link = Field(None, alias="samplingFeature@link") parameters: dict = Field(None) - phenomenon_time: TimePeriod = Field(None, alias="phenomenonTimeInterval") + phenomenon_time: TimePeriod = Field(None, alias="phenomenonTime") result_time: TimePeriod = Field(None, alias="resultTimeInterval") ds_type: str = Field(None, alias="type") result_type: str = Field(None, alias="resultType") + formats: List[str] = Field(default_factory=list) + observed_properties: List[dict] = Field(default_factory=list, alias="observedProperties") + system_id: str = Field(None, alias="system@id") links: List[Link] = Field(None) - record_schema: SerializeAsAny[DatastreamRecordSchema] = Field(None, alias="schema") + record_schema: AnyDatastreamRecordSchema = Field(None, alias="schema") @classmethod @model_validator(mode="before") @@ -175,6 +207,25 @@ def handle_aliases(cls, values): break return values + def to_csapi_dict(self) -> dict: + """Render this datastream as the CS API `application/json` resource + body. The embedded ``schema`` field is dumped polymorphically per + whichever variant (`SWEDatastreamRecordSchema` / + `OMJSONDatastreamRecordSchema`) it holds. + """ + return self.model_dump(by_alias=True, exclude_none=True, mode='json') + + def to_csapi_json(self) -> str: + """JSON-string variant of `to_csapi_dict`.""" + return json.dumps(self.to_csapi_dict()) + + @classmethod + def from_csapi_dict(cls, data: dict) -> "DatastreamResource": + """Build a `DatastreamResource` from a CS API datastream dict + (e.g., a server response body or an entry from a /datastreams + listing).""" + return cls.model_validate(data, by_alias=True) + class ObservationResource(BaseModel): model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True) @@ -187,6 +238,84 @@ class ObservationResource(BaseModel): result: dict = Field(...) result_link: Link = Field(None, alias="result@link") + def to_omjson_dict(self, datastream_id: str | None = None) -> dict: + """Render this observation as an `application/om+json` dict + (the ``ObservationOMJSONInline`` shape). + + :param datastream_id: Optional ID to include as ``datastream@id`` + on the output. The CS API typically supplies this from URL + context, so it's not required on the model itself. + """ + from .schema_datamodels import ObservationOMJSONInline + kwargs = {"result": self.result} + if datastream_id is not None: + kwargs["datastream_id"] = datastream_id + if self.phenomenon_time: + kwargs["phenomenon_time"] = self.phenomenon_time.get_iso_time() + if self.result_time: + kwargs["result_time"] = self.result_time.get_iso_time() + if self.parameters is not None: + kwargs["parameters"] = self.parameters + wrapper = ObservationOMJSONInline(**kwargs) + return wrapper.model_dump(by_alias=True, exclude_none=True, mode='json') + + def to_swejson_dict(self, schema: "AnyComponent" = None) -> dict: + """Render this observation as an `application/swe+json` payload + (the SWE Common JSON encoding of one record). + + SWE+JSON encodes a single observation as a flat JSON object whose + keys are the schema field names; ``self.result`` is already that + dict, so this is essentially a passthrough. The optional + ``schema`` argument is accepted for forward compatibility (when + we add field-order / encoding-aware emission). + """ + # ``schema`` reserved for future encoding rules (vector-as-arrays, + # JSONEncoding handling, etc.); current behavior is passthrough. + del schema + return dict(self.result) if self.result is not None else {} + + @classmethod + def from_omjson_dict(cls, data: dict) -> "ObservationResource": + """Build an `ObservationResource` from an `application/om+json` dict. + + Parses through `ObservationOMJSONInline` to validate the OM+JSON + envelope, then strips the ``datastream@id`` / ``foi@id`` envelope + fields (those live on the surrounding context, not the resource) + and returns the inner observation. + """ + from .schema_datamodels import ObservationOMJSONInline + wrapper = ObservationOMJSONInline.model_validate(data) + kwargs = { + "result_time": TimeInstant.from_string(wrapper.result_time), + "result": wrapper.result, + } + if wrapper.phenomenon_time: + kwargs["phenomenon_time"] = TimeInstant.from_string(wrapper.phenomenon_time) + if wrapper.parameters is not None: + kwargs["parameters"] = wrapper.parameters + return cls(**kwargs) + + @classmethod + def from_swejson_dict(cls, data: dict, schema: "AnyComponent" = None, + result_time: str | None = None) -> "ObservationResource": + """Build an `ObservationResource` from an `application/swe+json` + observation payload. + + SWE+JSON observations don't carry an envelope (no ``resultTime`` / + ``phenomenonTime`` fields); pass ``result_time`` explicitly when + you have it, otherwise the current UTC time is used. + + :param data: The flat SWE+JSON record dict. + :param schema: Optional schema, reserved for future per-field + type coercion. Currently ignored. + :param result_time: ISO 8601 timestamp for ``resultTime``; + defaults to ``TimeInstant.now_as_time_instant().isoformat()`` + if omitted. + """ + del schema # future use + rt = TimeInstant.from_string(result_time) if result_time is not None else TimeInstant.now_as_time_instant() + return cls(result_time=rt, result=dict(data)) + class ControlStreamResource(BaseModel): model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True) @@ -204,5 +333,24 @@ class ControlStreamResource(BaseModel): execution_time: TimePeriod = Field(None, alias="executionTime") live: bool = Field(None) asynchronous: bool = Field(True, alias="async") - command_schema: SerializeAsAny[CommandSchema] = Field(None, alias="schema") + command_schema: AnyCommandSchema = Field(None, alias="schema") links: List[Link] = Field(None) + + def to_csapi_dict(self) -> dict: + """Render this control stream as the CS API `application/json` + resource body. The embedded ``schema`` field is dumped + polymorphically per whichever variant + (`SWEJSONCommandSchema` / `JSONCommandSchema`) it holds. + """ + return self.model_dump(by_alias=True, exclude_none=True, mode='json') + + def to_csapi_json(self) -> str: + """JSON-string variant of `to_csapi_dict`.""" + return json.dumps(self.to_csapi_dict()) + + @classmethod + def from_csapi_dict(cls, data: dict) -> "ControlStreamResource": + """Build a `ControlStreamResource` from a CS API control-stream dict + (e.g., a server response body or an entry from a /controlstreams + listing).""" + return cls.model_validate(data, by_alias=True) diff --git a/src/oshconnect/resources/__init__.py b/src/oshconnect/resources/__init__.py new file mode 100644 index 0000000..8fa46a9 --- /dev/null +++ b/src/oshconnect/resources/__init__.py @@ -0,0 +1,35 @@ +# ============================================================================= +# Copyright (c) 2026 Georobotix Innovative Research +# Date: 2026/5/18 +# Author: Ian Patterson +# Contact Email: ian.patterson@georobotix.us +# ============================================================================= + +"""Streamable resource hierarchy: the user-facing wrappers for OSH systems, +datastreams, and control streams. + +The streaming-machinery base class (`StreamableResource`) and direction / +lifecycle enums live in `.base`; concrete subclasses live in `.system`, +`.datastream`, and `.controlstream`. Top-level imports continue to work +through `oshconnect.streamableresource` (re-export shim) and the package +`__init__`. +""" +from .base import ( + SchemaFetchWarning, + Status, + StreamableModes, + StreamableResource, +) +from .controlstream import ControlStream +from .datastream import Datastream +from .system import System + +__all__ = [ + "SchemaFetchWarning", + "Status", + "StreamableModes", + "StreamableResource", + "ControlStream", + "Datastream", + "System", +] diff --git a/src/oshconnect/resources/base.py b/src/oshconnect/resources/base.py new file mode 100644 index 0000000..2e9c740 --- /dev/null +++ b/src/oshconnect/resources/base.py @@ -0,0 +1,529 @@ +# ============================================================================= +# Copyright (c) 2026 Georobotix Innovative Research +# Date: 2026/5/18 +# Author: Ian Patterson +# Contact Email: ian.patterson@georobotix.us +# ============================================================================= + +"""Abstract `StreamableResource` base and the lifecycle / direction enums. + +This module is the shared streaming-machinery layer used by every concrete +resource wrapper (`System`, `Datastream`, `ControlStream`). It defines: + +- `SchemaFetchWarning` — surfaced from discovery when an individual + per-resource schema fetch fails. +- `Status` / `StreamableModes` — lifecycle and direction enums. +- `StreamableResource` — the ABC every concrete wrapper extends; owns + MQTT subscribe/publish, optional WebSocket I/O, inbound/outbound + deques, and the ``initialize → start → stop`` lifecycle. + +The concrete subclasses live in sibling modules +(`oshconnect.resources.system`, `oshconnect.resources.datastream`, +`oshconnect.resources.controlstream`) and the parent `Node` lives in +`oshconnect.node`. Public imports continue to resolve through +`oshconnect.streamableresource` (a re-export shim) and the package-level +`oshconnect.__init__`. +""" +from __future__ import annotations + +import asyncio +import json +import logging +import traceback +import uuid +from abc import ABC +from collections import deque +from enum import Enum +from multiprocessing import Process +from multiprocessing.queues import Queue +from typing import TYPE_CHECKING, Generic, TypeVar, Union +from uuid import UUID, uuid4 + +from ..csapi4py.constants import APIResourceTypes +from ..csapi4py.mqtt import MQTTCommClient +from ..resource_datamodels import ControlStreamResource +from ..resource_datamodels import DatastreamResource +from ..resource_datamodels import SystemResource +from ..timemanagement import TimePeriod + +if TYPE_CHECKING: + from ..node import Node + + +class SchemaFetchWarning(UserWarning): + """A datastream/control-stream schema fetch or parse failed during + `Node.discover_systems` / `System.discover_datastreams` / + `System.discover_controlstreams`. + + Discovery deliberately does not raise on per-resource schema failures — + one broken schema would otherwise poison the entire listing. The + matching wrapper is still appended (with `record_schema` / `command_schema` + left as ``None``), but the original exception is surfaced both here + (via ``warnings.warn``) and in the root logger at ERROR level (with a + full traceback via ``exc_info=True``). Filter or capture this category + if you want to react programmatically. + """ + + +class Status(Enum): + """Lifecycle states a `StreamableResource` transitions through: + ``STOPPED → INITIALIZING → INITIALIZED → STARTING → STARTED → STOPPING → STOPPED``.""" + INITIALIZING = "initializing" + INITIALIZED = "initialized" + STARTING = "starting" + STARTED = "started" + STOPPING = "stopping" + STOPPED = "stopped" + + +class StreamableModes(Enum): + """Direction(s) in which a streamable resource exchanges messages. + + - ``PUSH``: this client publishes outbound messages only. + - ``PULL``: this client subscribes to inbound messages only. + - ``BIDIRECTIONAL``: both publish and subscribe. + """ + PUSH = "push" + PULL = "pull" + BIDIRECTIONAL = "bidirectional" + + +T = TypeVar('T', SystemResource, DatastreamResource, ControlStreamResource) + + +class StreamableResource(Generic[T], ABC): + """Abstract base for `System`, `Datastream`, and `ControlStream`. + + Encapsulates the streaming machinery shared by all three: MQTT subscribe/ + publish, optional WebSocket I/O, inbound and outbound message deques, + and lifecycle (`initialize` → `start` → `stop`). Subclasses set + ``_underlying_resource`` (a `SystemResource` / `DatastreamResource` / + `ControlStreamResource` pydantic model) and override `init_mqtt` to + derive the appropriate topic. + + :param node: The parent `Node` this resource lives under. + :param connection_mode: One of `StreamableModes`. Default ``PUSH``. + """ + _id: UUID + _resource_id: str + # _canonical_link: str + _topic: str + _status: str = Status.STOPPED.value + ws_url: str + _message_handler = None + _parent_node: Node + _underlying_resource: T + _process: Process + _msg_reader_queue: asyncio.Queue[Union[str, bytes, float, int]] + _msg_writer_queue: asyncio.Queue[Union[str, bytes, float, int]] + _inbound_deque: deque + _outbound_deque: deque + _mqtt_client: MQTTCommClient + _parent_resource_id: str + _connection_mode: StreamableModes = StreamableModes.PUSH.value + + def __init__(self, node: Node, connection_mode: StreamableModes = StreamableModes.PUSH.value): + self._id = uuid4() + self._parent_node = node + self._parent_node.register_streamable(self) + self._mqtt_client = self._parent_node.get_mqtt_client() + self._connection_mode = connection_mode + self._inbound_deque = deque() + self._outbound_deque = deque() + self._parent_resource_id = None + + def get_streamable_id(self) -> UUID: + """Return the local UUID assigned at construction (not the server-side ID).""" + return self._id + + def get_streamable_id_str(self) -> str: + """Return the local UUID as a hex string.""" + return self._id.hex + + def initialize(self): + """Build the WebSocket URL, allocate I/O queues, and configure MQTT. + + Must be called before `start`. Inspects ``_underlying_resource`` to + determine the right resource type and constructs the WS URL via + the parent node's `APIHelper`. + + :raises ValueError: if ``_underlying_resource`` is not set or is + not one of System / Datastream / ControlStream. + """ + resource_type = None + if isinstance(self._underlying_resource, SystemResource): + resource_type = APIResourceTypes.SYSTEM + elif isinstance(self._underlying_resource, DatastreamResource): + resource_type = APIResourceTypes.DATASTREAM + elif isinstance(self._underlying_resource, ControlStreamResource): + resource_type = APIResourceTypes.CONTROL_CHANNEL + if resource_type is None: + raise ValueError( + "Underlying resource must be set to either SystemResource or DatastreamResource before initialization.") + # This needs to be implemented separately for each subclass + res_id = getattr(self._underlying_resource, "ds_id", None) or getattr(self._underlying_resource, "cs_id", None) + self.ws_url = self._parent_node.get_api_helper().construct_url(resource_type=resource_type, + subresource_type=APIResourceTypes.OBSERVATION, + resource_id=res_id, subresource_id=None) + self._msg_reader_queue = asyncio.Queue() + self._msg_writer_queue = asyncio.Queue() + self.init_mqtt() + self._status = Status.INITIALIZED.value + + def start(self): + """Subclasses override to also kick off MQTT subscribe / async write + tasks. Logs and returns silently if `initialize` hasn't been called. + """ + if self._status != Status.INITIALIZED.value: + logging.warning(f"Streamable resource {self._id} not initialized. Call initialize() first.") + return + self._status = Status.STARTING.value + self._status = Status.STARTED.value + + async def stream(self): + """Open a WebSocket to ``ws_url`` and run read/write loops in parallel. + + Used as an alternative to MQTT for resources that prefer WS streaming. + Reads incoming frames into the message handler and drains + ``_msg_writer_queue`` to the socket. + """ + session = self._parent_node.get_session() + + try: + async with session.ws_connect(self.ws_url, auth=self._parent_node.get_basicauth()) as ws: + logging.info(f"Streamable resource {self._id} started.") + read_task = asyncio.create_task(self._read_from_ws(ws)) + write_task = asyncio.create_task(self._write_to_ws(ws)) + await asyncio.gather(read_task, write_task) + except Exception as e: + logging.error(f"Error in streamable resource {self._id}: {e}") + logging.error(traceback.format_exc()) + + def init_mqtt(self): + """Wire the MQTT subscribe-acknowledged callback if a client exists. + + Subclasses override to additionally derive their resource-specific + topic into ``self._topic`` (see `Datastream.init_mqtt` / + `ControlStream.init_mqtt`). + """ + if self._mqtt_client is None: + logging.warning(f"No MQTT client configured for streamable resource {self._id}.") + return + + self._mqtt_client.set_on_subscribe(self._default_on_subscribe) + + # self.get_mqtt_topic() + + def _default_on_subscribe(self, client, userdata, mid, granted_qos, properties): + logging.debug("OSH Subscribed: mid=%s granted_qos=%s", mid, granted_qos) + + def get_mqtt_topic(self, subresource: APIResourceTypes | None = None, data_topic: bool = True, + format: str | None = None): + """ + Retrieves the MQTT topic for this streamable resource based on its underlying resource type. By default, + returns a Resource Data Topic (`:data` suffix per CS API Part 3). + :param subresource: Optional subresource type to get the topic for, defaults to None + :param data_topic: If True (default), produces a Resource Data Topic with ':data' suffix. Set False for + Resource Event Topics. + :param format: Optional MIME content-type for the ``:data/`` format subtopic. ``None`` (default) emits + bare ``:data`` so the server's default format applies. Ignored when ``data_topic=False``. + """ + resource_type = None + parent_res_type = None + parent_id = None + + if isinstance(self._underlying_resource, ControlStreamResource): + parent_res_type = APIResourceTypes.CONTROL_CHANNEL + parent_id = self._resource_id + + match subresource: + case APIResourceTypes.COMMAND: + resource_type = APIResourceTypes.COMMAND + case APIResourceTypes.STATUS: + resource_type = APIResourceTypes.STATUS + + elif isinstance(self._underlying_resource, DatastreamResource): + parent_res_type = APIResourceTypes.DATASTREAM + resource_type = APIResourceTypes.OBSERVATION + parent_id = self._resource_id + + elif isinstance(self._underlying_resource, SystemResource): + match subresource: + case APIResourceTypes.DATASTREAM: + resource_type = APIResourceTypes.DATASTREAM + parent_res_type = APIResourceTypes.SYSTEM + parent_id = self._resource_id + case APIResourceTypes.CONTROL_CHANNEL: + resource_type = APIResourceTypes.CONTROL_CHANNEL + parent_res_type = APIResourceTypes.SYSTEM + parent_id = self._resource_id + case None: + resource_type = APIResourceTypes.SYSTEM + parent_res_type = None + parent_id = None + case _: + raise ValueError(f"Unsupported subresource type {subresource} for SystemResource.") + + topic = self._parent_node.get_api_helper().get_mqtt_topic(subresource_type=resource_type, resource_id=parent_id, + resource_type=parent_res_type, data_topic=data_topic, + format=format) + return topic + + def get_event_topic(self) -> str: + """ + Returns the Resource Event Topic for this streamable resource per CS API Part 3. Event topics point to the + resource itself (no ':data' suffix) and are used to receive CloudEvents lifecycle notifications + (create/update/delete) published by the server. + + For Datastream/ControlStream, includes the parent system path when a parent resource ID is available. + """ + mqtt_root = self._parent_node.get_api_helper().get_mqtt_root() + + if isinstance(self._underlying_resource, DatastreamResource): + if self._parent_resource_id: + return f'{mqtt_root}/systems/{self._parent_resource_id}/datastreams/{self._resource_id}' + return f'{mqtt_root}/datastreams/{self._resource_id}' + + elif isinstance(self._underlying_resource, ControlStreamResource): + if self._parent_resource_id: + return f'{mqtt_root}/systems/{self._parent_resource_id}/controlstreams/{self._resource_id}' + return f'{mqtt_root}/controlstreams/{self._resource_id}' + + elif isinstance(self._underlying_resource, SystemResource): + return f'{mqtt_root}/systems/{self._resource_id}' + + raise ValueError(f"Cannot determine event topic for resource type {type(self._underlying_resource)}") + + def subscribe_events(self, callback=None, qos: int = 0) -> str: + """ + Subscribes to the Resource Event Topic for this streamable resource. Event messages are CloudEvents v1.0 + JSON payloads published by the server when the resource is created, updated, or deleted. + + :param callback: Optional message callback. If None, uses the default handler (appends to inbound deque). + :param qos: MQTT Quality of Service level, default 0. + :return: The event topic string that was subscribed to. + """ + if self._mqtt_client is None: + logging.warning(f"No MQTT client configured for streamable resource {self._id}.") + return "" + event_topic = self.get_event_topic() + cb = callback if callback is not None else self._mqtt_sub_callback + self._mqtt_client.subscribe(event_topic, qos=qos, msg_callback=cb) + return event_topic + + async def _read_from_ws(self, ws): + async for msg in ws: + self._message_handler(ws, msg) + + async def _write_to_ws(self, ws): + while self._status is Status.STARTED.value: + try: + msg = self._msg_writer_queue.get_nowait() + await ws.send_bytes(msg) + except asyncio.QueueEmpty: + await asyncio.sleep(0.05) + + def stop(self): + """Tear down the streaming process and mark the resource ``STOPPED``. + + Note: currently calls ``Process.terminate()``; cleaner shutdown + (graceful drain, auth state preservation) is a known follow-up. + """ + # It would be nicer to join() here once we have cleaner shutdown logic in place to avoid corrupting processes + # that are writing to streams or that need to manage authentication state + self._status = "stopping" + self._process.terminate() + self._status = "stopped" + + def set_parent_node(self, node: Node): + """Attach this resource to the given `Node`.""" + self._parent_node = node + + def get_parent_node(self) -> Node: + """Return the `Node` this resource is attached to.""" + return self._parent_node + + def set_parent_resource_id(self, res_id: str): + """Set the server-side ID of the parent resource (e.g. the parent + System for a Datastream / ControlStream).""" + self._parent_resource_id = res_id + + def get_parent_resource_id(self) -> str: + """Return the server-side ID of the parent resource, if set.""" + return self._parent_resource_id + + def set_connection_mode(self, connection_mode: StreamableModes): + """Switch direction (PUSH / PULL / BIDIRECTIONAL).""" + self._connection_mode = connection_mode + + def poll(self): + """Poll for new data. Hook for subclass implementations; no-op here.""" + pass + + def fetch(self, time_period: TimePeriod): + """Fetch data over a `TimePeriod`. Hook for subclass implementations; no-op here.""" + pass + + def get_msg_reader_queue(self) -> Queue: + """ + Returns the message queue for this streamable resource. In cases where a custom message handler is used this is + not guaranteed to return anything or provided a queue with data. + :return: Queue object + """ + return self._msg_reader_queue + + def get_msg_writer_queue(self) -> Queue: + """ + Returns the message queue for writing messages to this streamable resource. + :return: Queue object + """ + return self._msg_writer_queue + + def get_underlying_resource(self) -> T: + """Return the pydantic resource model (System/Datastream/ControlStream) + that backs this streamable.""" + return self._underlying_resource + + def get_internal_id(self) -> UUID: + """Return the local UUID. Alias for `get_streamable_id`.""" + return self._id + + def insert_data(self, data): + """ Inserts data into the message writer queue to be sent over the WebSocket / MQTT connection. + Encoding is delegated to `_encode_for_wire`, which subclasses can override to honour + their datastream's wire format (e.g. `Datastream` routes through `SWEBinaryCodec` when + its schema is `application/swe+binary`). No semantic validation is performed. + :param data: Data to be sent (dict, sequence, or bytes-like). + """ + self._msg_writer_queue.put_nowait(self._encode_for_wire(data)) + + def _encode_for_wire(self, data) -> bytes: + """Default wire encoding: pass `bytes`-likes through, else `json.dumps`. + + Subclasses with format-specific codecs (see `Datastream._encode_for_wire`) + override this. Single hook so changing the encoding policy on one path + does not silently leave the other path producing stale bytes. + """ + if isinstance(data, (bytes, bytearray, memoryview)): + return bytes(data) + return json.dumps(data).encode("utf-8") + + def subscribe_mqtt(self, topic: str, qos: int = 0): + """Subscribe to an arbitrary MQTT ``topic`` using the default callback + (appends incoming payloads to ``_inbound_deque``). + + :param topic: MQTT topic string. The caller is responsible for any + topic-prefix conventions (CS API Part 3 ``:data`` etc.). + :param qos: MQTT QoS level. Default 0. + """ + if self._mqtt_client is None: + logging.warning(f"No MQTT client configured for streamable resource {self._id}.") + return + self._mqtt_client.subscribe(topic, qos=qos, msg_callback=self._mqtt_sub_callback) + + def _publish_mqtt(self, topic, payload): + if self._mqtt_client is None: + logging.warning("No MQTT client configured for streamable resource %s.", self._id) + return + logging.debug("Publishing to MQTT topic %s", topic) + self._mqtt_client.publish(topic, payload, qos=0) + + async def _write_to_mqtt(self): + while self._status == Status.STARTED.value: + try: + msg = self._outbound_deque.popleft() + logging.debug("Publishing outbound message from %s", self._id) + self._publish_mqtt(self._topic, msg) + except IndexError: + await asyncio.sleep(0.05) + except Exception as e: + logging.error("Error in Write To MQTT %s: %s\n%s", self._id, e, traceback.format_exc()) + if self._status == Status.STOPPED.value: + logging.debug("MQTT write task stopping: resource %s stopped", self._id) + + def publish(self, payload, topic: str = None): + """ + Publishes data to the MQTT topic associated with this streamable resource. + :param payload: Data to be published, subclass should determine specifically allowed types + :param topic: Specific implementation determines the topic from the provided string, if None the default topic is used + """ + self._publish_mqtt(self._topic, payload) + + def subscribe(self, topic=None, callback=None, qos=0): + """ + Subscribes to the MQTT topic associated with this streamable resource. + :param topic: Specific implementation determines the topic from the provided string, if None the default topic is used + :param callback: Optional callback function to handle incoming messages, if None the default handler is used + :param qos: Quality of Service level for the subscription, default is 0 + """ + t = None + + if topic is None: + t = self._topic + else: + raise ValueError("Invalid topic provided, must be None to use default topic.") + + if callback is None: + self._mqtt_client.subscribe(t, qos=qos, msg_callback=self._mqtt_sub_callback) + else: + self._mqtt_client.subscribe(t, qos=qos, msg_callback=callback) + + def _mqtt_sub_callback(self, client, userdata, msg): + logging.debug("Received MQTT message on topic %s (%s bytes)", msg.topic, len(msg.payload)) + # Appends to right of deque + self._inbound_deque.append(msg.payload) + self._emit_inbound_event(msg) + + def _emit_inbound_event(self, msg): + """Hook for subclasses to publish EventHandler events on incoming MQTT messages.""" + pass + + def get_inbound_deque(self) -> deque: + """Return the deque that receives inbound MQTT message payloads.""" + return self._inbound_deque + + def get_outbound_deque(self) -> deque: + """Return the deque feeding outbound MQTT publishes.""" + return self._outbound_deque + + def to_storage_dict(self) -> dict: + """Return a JSON-safe snapshot of the streamable's identity and + connection state, for OSHConnect's persistence layer. Subclasses + extend this with their own fields and the dumped underlying + resource. Safely handles missing / None attributes. + + Not a CS API server-shaped payload. + """ + topic = getattr(self, "_topic", None) + status = getattr(self, "_status", None) + parent_resource_id = getattr(self, "_parent_resource_id", None) + connection_mode = getattr(self, "_connection_mode", None) + resource_id = getattr(self, "_resource_id", None) + if isinstance(connection_mode, Enum): + connection_mode = connection_mode.value + + return { + "id": str(getattr(self, "_id", None)), + "resource_id": resource_id, + # "canonical_link": getattr(self, "_canonical_link", None), + "topic": topic, + "status": status, + "parent_resource_id": parent_resource_id, + "connection_mode": connection_mode, + } + + @classmethod + def from_storage_dict(cls, data: dict, node: 'Node') -> 'StreamableResource': + """Rebuild common attributes from a `to_storage_dict` payload. + Subclasses override and call ``super()`` to wire in their own + fields and the underlying resource. + """ + obj = cls(node=node) + obj._id = uuid.UUID(data["id"]) + obj._resource_id = data.get("resource_id") + # obj._canonical_link = data.get("canonical_link") + obj._topic = data.get("topic") + obj._status = data.get("status") + obj._parent_resource_id = data.get("parent_resource_id") + obj._connection_mode = StreamableModes(data.get("connection_mode", StreamableModes.PUSH.value)), + return obj diff --git a/src/oshconnect/resources/controlstream.py b/src/oshconnect/resources/controlstream.py new file mode 100644 index 0000000..c6827e5 --- /dev/null +++ b/src/oshconnect/resources/controlstream.py @@ -0,0 +1,229 @@ +# ============================================================================= +# Copyright (c) 2026 Georobotix Innovative Research +# Date: 2026/5/18 +# Author: Ian Patterson +# Contact Email: ian.patterson@georobotix.us +# ============================================================================= + +"""`ControlStream` — an input channel of a `System` that accepts commands. + +Concrete `StreamableResource` subclass with two MQTT topics +(``self._topic`` for commands, ``self._status_topic`` for status updates) +and two pairs of inbound/outbound deques to match. +""" +from __future__ import annotations + +import asyncio +import logging +import traceback +import uuid +from collections import deque +from typing import TYPE_CHECKING + +from ..csapi4py.constants import APIResourceTypes +from ..events import DefaultEventTypes, EventHandler +from ..events.builder import EventBuilder +from ..resource_datamodels import ControlStreamResource +from .base import StreamableModes, StreamableResource + +if TYPE_CHECKING: + from ..node import Node + + +class ControlStream(StreamableResource[ControlStreamResource]): + """An input channel of a `System`: accepts commands and emits status. + + Unlike `Datastream`, a control stream has TWO MQTT topics — one for + commands (``self._topic``) and one for status updates + (``self._status_topic``) — and two pairs of inbound/outbound deques to + match. Construct from a parsed `ControlStreamResource` (typically from + `System.discover_controlstreams`) or build locally and insert via + `System.add_and_insert_control_stream`. + + :param node: The `Node` this control stream lives under. + :param controlstream_resource: The pydantic `ControlStreamResource` + model that backs this stream. + """ + _status_topic: str + _inbound_status_deque: deque + _outbound_status_deque: deque + + def __init__(self, node: Node = None, controlstream_resource: ControlStreamResource = None): + super().__init__(node=node) + self._underlying_resource = controlstream_resource + self._inbound_status_deque = deque() + self._outbound_status_deque = deque() + self._resource_id = controlstream_resource.cs_id + # Always make sure this is set after the resource ids are set + self._status_topic = self.get_mqtt_status_topic() + + def add_underlying_resource(self, resource: ControlStreamResource): + """Replace the underlying `ControlStreamResource` model.""" + self._underlying_resource = resource + + def get_id(self) -> str: + """Return the server-side control-stream ID.""" + return self._underlying_resource.cs_id + + def init_mqtt(self): + """Set ``self._topic`` to the control stream's command data topic. + When this control stream has a ``command_schema`` the topic is + suffixed with the matching format subtopic (e.g. + ``…/commands:data/swe-json``); otherwise a bare ``:data`` topic is + used and the server's default format applies.""" + super().init_mqtt() + schema = getattr(self._underlying_resource, "command_schema", None) + cmd_format = getattr(schema, "command_format", None) if schema is not None else None + self._topic = self.get_mqtt_topic(subresource=APIResourceTypes.COMMAND, + data_topic=True, format=cmd_format) + + def get_mqtt_status_topic(self) -> str: + """Return the MQTT topic for command status updates. Status payloads + are always ``application/json``, so the topic is suffixed with the + ``json`` format subtopic (``…/status:data/json``).""" + return self.get_mqtt_topic(subresource=APIResourceTypes.STATUS, + data_topic=True, format="application/json") + + def _emit_inbound_event(self, msg): + evt_type = (DefaultEventTypes.NEW_COMMAND if msg.topic == self._topic else DefaultEventTypes.NEW_COMMAND_STATUS) + evt = ( + EventBuilder().with_type(evt_type).with_topic(msg.topic).with_data(msg.payload).with_producer(self).build()) + EventHandler().publish(evt) + + def start(self): + """Start the control stream. PULL/BIDIRECTIONAL subscribes to the + command topic; PUSH spawns the async MQTT write loop. Requires + an active asyncio event loop for PUSH mode. + """ + super().start() + if self._mqtt_client is not None: + if self._connection_mode is StreamableModes.PULL or self._connection_mode is StreamableModes.BIDIRECTIONAL: + # Subs to command topic by default + self._mqtt_client.subscribe(self._topic, msg_callback=self._mqtt_sub_callback) + else: + try: + loop = asyncio.get_running_loop() + loop.create_task(self._write_to_mqtt()) + except RuntimeError: + logging.warning("No running event loop — MQTT write task for %s not started. " + "Call start() from within an async context.", self._id) + except Exception as e: + logging.error("Error starting MQTT write task for %s: %s\n%s", self._id, e, traceback.format_exc()) + + def get_inbound_deque(self) -> deque: + """Return the deque receiving inbound command payloads.""" + return self._inbound_deque + + def get_outbound_deque(self) -> deque: + """Return the deque feeding outbound command publishes.""" + return self._outbound_deque + + def get_status_deque_inbound(self) -> deque: + """Return the deque receiving inbound status updates.""" + return self._inbound_status_deque + + def get_status_deque_outbound(self) -> deque: + """Return the deque feeding outbound status publishes.""" + return self._outbound_status_deque + + def publish_command(self, payload): + """Publish ``payload`` to the command MQTT topic. Convenience wrapper + for ``publish(payload, APIResourceTypes.COMMAND.value)``.""" + self.publish(payload, topic=APIResourceTypes.COMMAND.value) + + def publish_status(self, payload): + """Publish ``payload`` to the status MQTT topic. Convenience wrapper + for ``publish(payload, APIResourceTypes.STATUS.value)``.""" + self.publish(payload, topic=APIResourceTypes.STATUS.value) + + def publish(self, payload, topic: str = APIResourceTypes.COMMAND.value): + """ + Publishes data to the MQTT topic associated with this control stream resource. + + :param payload: Data to be published; subclass determines specifically allowed types. + :param topic: One of ``APIResourceTypes.COMMAND.value`` (``"Command"``, + the default) or ``APIResourceTypes.STATUS.value`` (``"Status"``). + Pass the enum value rather than a lowercase shorthand — the + comparison is case-sensitive against the canonical CS API + resource-type strings. + """ + + if topic == APIResourceTypes.COMMAND.value: + self._publish_mqtt(self._topic, payload) + elif topic == APIResourceTypes.STATUS.value: + self._publish_mqtt(self._status_topic, payload) + else: + raise ValueError( + f"Unsupported topic {topic!r} for ControlStream publish(); " + f"expected {APIResourceTypes.COMMAND.value!r} or " + f"{APIResourceTypes.STATUS.value!r}." + ) + + def subscribe(self, topic=None, callback=None, qos=0): + """ + Subscribes to the MQTT topic associated with this control stream resource. + + :param topic: ``None`` (defaults to the command topic), + ``APIResourceTypes.COMMAND.value`` (``"Command"``), or + ``APIResourceTypes.STATUS.value`` (``"Status"``). Comparison is + case-sensitive against the canonical CS API resource-type strings. + :param callback: Optional callback function to handle incoming messages, if None the default handler is used. + :param qos: Quality of Service level for the subscription, default is 0. + """ + + t = None + + if topic is None or topic == APIResourceTypes.COMMAND.value: + t = self._topic + elif topic == APIResourceTypes.STATUS.value: + t = self._status_topic + else: + raise ValueError( + f"Invalid topic {topic!r}; must be None, " + f"{APIResourceTypes.COMMAND.value!r}, or " + f"{APIResourceTypes.STATUS.value!r}." + ) + + if callback is None: + self._mqtt_client.subscribe(t, qos=qos, msg_callback=self._mqtt_sub_callback) + else: + self._mqtt_client.subscribe(t, qos=qos, msg_callback=callback) + + def to_storage_dict(self) -> dict: + """Return a JSON-safe snapshot of this control stream — local + identity, connection state, status topic, and the dumped underlying + `ControlStreamResource` — for OSHConnect's persistence layer. + + Not a CS API server-shaped payload — the ``underlying_resource`` + block is the only piece that matches the CS API control-stream + shape. + """ + data = super().to_storage_dict() + data["status_topic"] = getattr(self, "_status_topic", None) + underlying = getattr(self, "_underlying_resource", None) + if underlying is not None: + dump = getattr(underlying, 'model_dump', None) + if callable(dump): + data["underlying_resource"] = underlying.model_dump(by_alias=True, exclude_none=True, mode='json') + elif hasattr(underlying, 'to_dict'): + data["underlying_resource"] = underlying.to_dict() + else: + data["underlying_resource"] = str(underlying) + else: + data["underlying_resource"] = None + + return data + + @classmethod + def from_storage_dict(cls, data: dict, node: 'Node') -> 'ControlStream': + """Build a `ControlStream` from a dict produced by `to_storage_dict`. + The embedded ``underlying_resource`` is parsed via + `ControlStreamResource.model_validate`, so that nested block can + also be a CS API server response body for the control stream. + """ + cs_resource = ControlStreamResource.model_validate(data["underlying_resource"]) if data.get( + "underlying_resource") else None + obj = cls(node=node, controlstream_resource=cs_resource) + obj._id = uuid.UUID(data["id"]) + obj._status_topic = data.get("status_topic") + return obj diff --git a/src/oshconnect/resources/datastream.py b/src/oshconnect/resources/datastream.py new file mode 100644 index 0000000..91524a9 --- /dev/null +++ b/src/oshconnect/resources/datastream.py @@ -0,0 +1,292 @@ +# ============================================================================= +# Copyright (c) 2026 Georobotix Innovative Research +# Date: 2026/5/18 +# Author: Ian Patterson +# Contact Email: ian.patterson@georobotix.us +# ============================================================================= + +"""`Datastream` — an output channel of a `System` that produces observations. + +Concrete `StreamableResource` subclass. Each datastream owns its observation +MQTT topic (CS API Part 3 ``:data``) and bridges between the user's +``insert(...)`` / ``insert_observation_dict(...)`` calls and the OSH server. +""" +from __future__ import annotations + +import asyncio +import json +import logging +import traceback +import uuid +import warnings +from typing import TYPE_CHECKING + +from ..csapi4py.constants import APIResourceTypes +from ..events import DefaultEventTypes, EventHandler +from ..events.builder import EventBuilder +from ..resource_datamodels import DatastreamResource, ObservationResource +from ..schema_datamodels import ( + SWEBinaryDatastreamRecordSchema, + SWEFlatBuffersDatastreamRecordSchema, + SWEProtobufDatastreamRecordSchema, +) +from ..swe_binary import SWEBinaryCodec +from ..timemanagement import TimeInstant +from .base import StreamableModes, StreamableResource + +if TYPE_CHECKING: + from ..node import Node + + +class Datastream(StreamableResource[DatastreamResource]): + """An output channel of a `System`: produces observations. + + Created from a parsed `DatastreamResource` (typically returned by + `System.discover_datastreams`) or built locally and inserted via + `System.add_insert_datastream`. Subscribes to its observation MQTT + topic when started. + + :param parent_node: The `Node` this datastream lives under. + :param datastream_resource: The pydantic `DatastreamResource` model. + """ + should_poll: bool + + def __init__(self, parent_node: Node = None, datastream_resource: DatastreamResource = None): + super().__init__(node=parent_node) + self._underlying_resource = datastream_resource + self._resource_id = datastream_resource.ds_id + + def get_id(self) -> str: + """Return the server-side datastream ID.""" + return self._underlying_resource.ds_id + + @staticmethod + def from_resource(ds_resource: DatastreamResource, parent_node: Node) -> 'Datastream': + """Build a `Datastream` from an already-parsed `DatastreamResource`. + + .. deprecated:: 0.5.1 + Use the constructor directly instead: + ``Datastream(parent_node=node, datastream_resource=ds_resource)``. + For raw JSON, parse first via ``DatastreamResource.from_csapi_dict(data)``. + """ + warnings.warn( + "Datastream.from_resource is deprecated; pass datastream_resource directly " + "to the constructor: Datastream(parent_node=node, datastream_resource=res). " + "For raw JSON, parse via DatastreamResource.from_csapi_dict(data) first.", + DeprecationWarning, stacklevel=2, + ) + return Datastream(parent_node=parent_node, datastream_resource=ds_resource) + + def set_resource(self, resource: DatastreamResource): + """Replace the underlying `DatastreamResource` model.""" + self._underlying_resource = resource + + def get_resource(self) -> DatastreamResource: + """Return the underlying `DatastreamResource` model.""" + return self._underlying_resource + + def create_observation(self, obs_data: dict) -> ObservationResource: + """Build an `ObservationResource` from a result dict, validating + against this datastream's record schema if one is set. + + Does NOT insert the observation server-side — pair with + `insert_observation_dict` if you want to POST it. + """ + obs = ObservationResource(result=obs_data, result_time=TimeInstant.now_as_time_instant()) + # Validate against the schema + if self._underlying_resource.record_schema is not None: + obs.validate_against_schema(self._underlying_resource.record_schema) + return obs + + def insert_observation_dict(self, obs_data: dict): + """POST an observation dict to ``/datastreams/{id}/observations``. + + :raises Exception: if the server returns a non-OK response. + """ + res = self._parent_node.get_api_helper().create_resource(APIResourceTypes.OBSERVATION, obs_data, + parent_res_id=self._resource_id, + req_headers={'Content-Type': 'application/json'}) + if res.ok: + obs_id = res.headers['Location'].split('/')[-1] + return obs_id + else: + raise Exception(f'Failed to insert observation: {res.text}') + + def start(self): + """Start the datastream. PULL/BIDIRECTIONAL subscribes to the + observation topic; PUSH spawns the async MQTT write loop. Requires + an active asyncio event loop for PUSH mode. + """ + super().start() + if self._mqtt_client is not None: + if self._connection_mode is StreamableModes.PULL or self._connection_mode is StreamableModes.BIDIRECTIONAL: + self._mqtt_client.subscribe(self._topic, msg_callback=self._mqtt_sub_callback) + else: + try: + loop = asyncio.get_running_loop() + loop.create_task(self._write_to_mqtt()) + except RuntimeError: + logging.warning("No running event loop — MQTT write task for %s not started. " + "Call start() from within an async context.", self._id) + except Exception as e: + logging.error("Error starting MQTT write task for %s: %s\n%s", self._id, e, traceback.format_exc()) + + def init_mqtt(self): + """Set ``self._topic`` to the datastream's observation data topic + (CS API Part 3 ``:data`` suffix). When this datastream has a + ``record_schema`` the topic is suffixed with the matching format + subtopic (e.g. ``…/observations:data/swe-binary``); otherwise a + bare ``:data`` topic is used and the server's default format + applies.""" + super().init_mqtt() + schema = getattr(self._underlying_resource, "record_schema", None) + obs_format = getattr(schema, "obs_format", None) if schema is not None else None + self._topic = self.get_mqtt_topic(subresource=APIResourceTypes.OBSERVATION, + data_topic=True, format=obs_format) + + def _emit_inbound_event(self, msg): + evt = (EventBuilder().with_type(DefaultEventTypes.NEW_OBSERVATION).with_topic(msg.topic).with_data( + msg.payload).with_producer(self).build()) + EventHandler().publish(evt) + + def _queue_push(self, msg): + self._msg_writer_queue.put_nowait(msg) + + def _queue_pop(self): + return self._msg_reader_queue.get_nowait() + + def insert(self, data): + """Encode ``data`` and publish it to this datastream's observation + MQTT topic. Bypasses the outbound deque. + + Encoding is chosen from the datastream's record schema: + + * ``application/swe+binary`` → uses `SWEBinaryCodec` to pack a + dict (or `Sequence` in declared member order) into the binary + wire form. Raw ``bytes``/``bytearray``/``memoryview`` payloads + are passed through verbatim — useful when the caller has + already framed a record (e.g. a pre-encoded H.264 NAL unit + with the standard ``[ts][size][bytes]`` blob framing from + ``oshconnect.swe_binary.encode_swe_binary_blob``). + * everything else (incl. ``application/swe+json``, + ``application/om+json``) → ``json.dumps`` of a dict. + """ + encoded = self._encode_for_wire(data) + self._publish_mqtt(self._topic, encoded) + + def _encode_for_wire(self, data) -> bytes: + """Encode ``data`` for publish over this datastream's wire format. + + Single source of truth used by both `insert` (MQTT bypass) and + ``base.StreamableResource.insert_data`` (deque-routed) via the + ``_streamable_encode_payload`` hook on `StreamableResource`. + Keeping the dispatch here means changing the encoding policy + does not require touching both call sites. + """ + # Already-encoded bytes pass through. Lets callers ship a + # pre-framed binary blob (or a hand-built JSON dict) without + # going through the codec. + if isinstance(data, (bytes, bytearray, memoryview)): + return bytes(data) + schema = getattr(self._underlying_resource, "record_schema", None) + if isinstance(schema, SWEBinaryDatastreamRecordSchema): + return SWEBinaryCodec(schema).encode(data) + if isinstance(schema, SWEProtobufDatastreamRecordSchema): + from ..swe_protobuf import SWEProtobufCodec # lazy: optional dep + return SWEProtobufCodec(schema).encode(data) + if isinstance(schema, SWEFlatBuffersDatastreamRecordSchema): + from ..swe_flatbuffers import SWEFlatBuffersCodec # lazy: stub + return SWEFlatBuffersCodec(schema).encode(data) + # JSON-family fallback (om+json, swe+json, swe+csv-handed-a-dict). + return json.dumps(data).encode("utf-8") + + def decode_observation(self, raw: bytes) -> dict: + """Decode one observation off the wire using this datastream's schema. + + For ``application/swe+binary`` datastreams: walks the record + encoding's members and returns a dict keyed by field name. Block + members come back as ``bytes`` (opaque — the codec does not + demux H.264 / JPEG / etc.). + + For JSON-family datastreams: returns ``json.loads(raw)``. + + :raises ValueError: if no schema has been fetched. + """ + schema = getattr(self._underlying_resource, "record_schema", None) + if schema is None: + raise ValueError( + "Cannot decode observation: no record_schema on this " + "datastream. Call System.discover_datastreams() first, " + "or set record_schema manually.") + if isinstance(schema, SWEBinaryDatastreamRecordSchema): + return SWEBinaryCodec(schema).decode(raw) + if isinstance(schema, SWEProtobufDatastreamRecordSchema): + from ..swe_protobuf import SWEProtobufCodec # lazy: optional dep + return SWEProtobufCodec(schema).decode(raw) + if isinstance(schema, SWEFlatBuffersDatastreamRecordSchema): + from ..swe_flatbuffers import SWEFlatBuffersCodec # lazy: stub + return SWEFlatBuffersCodec(schema).decode(raw) + return json.loads(raw) + + def to_storage_dict(self) -> dict: + """Return a JSON-safe snapshot of this datastream — local identity, + connection state, polling flag, and the dumped underlying + `DatastreamResource` — for OSHConnect's persistence layer. + + Not a CS API server-shaped payload — the ``underlying_resource`` + block is the only piece that matches the CS API datastream shape. + """ + data = super().to_storage_dict() + data["should_poll"] = getattr(self, "should_poll", None) + underlying = getattr(self, "_underlying_resource", None) + if underlying is not None: + dump = getattr(underlying, 'model_dump', None) + if callable(dump): + data["underlying_resource"] = underlying.model_dump(by_alias=True, exclude_none=True, mode='json') + elif hasattr(underlying, 'to_dict'): + data["underlying_resource"] = underlying.to_dict() + else: + data["underlying_resource"] = str(underlying) + else: + data["underlying_resource"] = None + + return data + + @classmethod + def from_storage_dict(cls, data: dict, node: 'Node') -> 'Datastream': + """Build a `Datastream` from a dict produced by `to_storage_dict`. + The embedded ``underlying_resource`` is parsed via + `DatastreamResource.model_validate`, so that nested block can also + be a CS API server response body for the datastream. + """ + ds_resource = DatastreamResource.model_validate(data["underlying_resource"]) if data.get( + "underlying_resource") else None + obj = cls(parent_node=node, datastream_resource=ds_resource) + obj._id = uuid.UUID(data["id"]) + obj.should_poll = data.get("should_poll", False) + return obj + + def subscribe(self, topic=None, callback=None, qos=0): + """Subscribe to this datastream's observation MQTT topic. + + :param topic: ``None`` or ``"observation"`` — both resolve to the + datastream's data topic. Any other string raises. + :param callback: Override the default callback (which appends + payloads to ``_inbound_deque``). + :param qos: MQTT QoS level. Default 0. + :raises ValueError: if ``topic`` is anything other than None / + ``"observation"``. + """ + t = None + + if topic is None or topic == APIResourceTypes.OBSERVATION.value: + t = self._topic + # elif topic == APIResourceTypes.STATUS.value: + # t = self._status_topic + else: + raise ValueError(f"Invalid topic provided {topic}, must be None or 'observation'.") + + if callback is None: + self._mqtt_client.subscribe(t, qos=qos, msg_callback=self._mqtt_sub_callback) + else: + self._mqtt_client.subscribe(t, qos=qos, msg_callback=callback) diff --git a/src/oshconnect/resources/system.py b/src/oshconnect/resources/system.py new file mode 100644 index 0000000..9bf2d6f --- /dev/null +++ b/src/oshconnect/resources/system.py @@ -0,0 +1,640 @@ +# ============================================================================= +# Copyright (c) 2026 Georobotix Innovative Research +# Date: 2026/5/18 +# Author: Ian Patterson +# Contact Email: ian.patterson@georobotix.us +# ============================================================================= + +"""`System` — a sensor system on an OSH server. + +Concrete `StreamableResource` subclass. Logical grouping of one or more +`Datastream` outputs and `ControlStream` inputs sharing a single URN. +Exposes discovery and creation flows for both child resource types. +""" +from __future__ import annotations + +import datetime +import logging +import uuid +import warnings +from typing import TYPE_CHECKING + +from ..csapi4py.constants import APIResourceTypes, ContentTypes +from ..encoding import JSONEncoding +from ..resource_datamodels import ControlStreamResource, DatastreamResource, SystemResource +from ..schema_datamodels import ( + JSONCommandSchema, SWEBinaryDatastreamRecordSchema, + SWEDatastreamRecordSchema, SWEFlatBuffersDatastreamRecordSchema, + SWEJSONCommandSchema, SWEProtobufDatastreamRecordSchema, +) +from ..swe_components import DataRecordSchema +from ..timemanagement import TimeInstant, TimePeriod, TimeUtils +from .base import SchemaFetchWarning, StreamableResource +from .controlstream import ControlStream +from .datastream import Datastream + +if TYPE_CHECKING: + from ..node import Node + + +class System(StreamableResource[SystemResource]): + """A sensor system on an OSH server: a logical grouping of one or more + `Datastream` outputs and `ControlStream` inputs sharing a single URN. + + Construct directly to define a new system, or build one from a parsed + `SystemResource` via `from_system_resource`. Use `discover_datastreams` / + `discover_controlstreams` to populate child resources from the server, + or `add_insert_datastream` / `add_and_insert_control_stream` to create + new ones server-side. + """ + label: str + datastreams: list[Datastream] + control_channels: list[ControlStream] + description: str + urn: str + _parent_node: Node + + def __init__(self, label: str = None, urn: str = None, parent_node: Node = None, **kwargs): + """ + :param label: The display string for the system. Maps to SML's + ``label`` and GeoJSON's ``properties.name`` on the wire — + the OGC CS API only carries one display string per system. + :param urn: The URN of the system, typically formed as such: + ``'urn:general_identifier:specific_identifier:…'``. + :param parent_node: The `Node` this system attaches to. + :param kwargs: + - 'description': A description of the system + - 'resource_id': The server-assigned ID once known + - 'name': Deprecated alias for ``label``. Emits + ``DeprecationWarning``; if ``label`` is also supplied, + ``name`` is ignored. Will be removed in a future release. + """ + super().__init__(node=parent_node) + + # Back-compat: `name` was a separate constructor parameter that + # always carried the same value as `label` because the wire only + # has one display string. Route deprecated callers to `label`. + if 'name' in kwargs: + import warnings + warnings.warn( + "`System(name=...)` is deprecated; use `label=` instead. " + "The wire-format only carries one display string per " + "system and `name` was always populated from the same " + "source as `label`.", + DeprecationWarning, stacklevel=2, + ) + legacy_name = kwargs.pop('name') + if label is None: + label = legacy_name + + self.label = label + self.datastreams = [] + self.control_channels = [] + self.urn = urn + if kwargs.get('resource_id'): + self._resource_id = kwargs['resource_id'] + if kwargs.get('description'): + self.description = kwargs['description'] + + self._underlying_resource = self.to_system_resource() + + @property + def name(self) -> str: + """Deprecated alias for `label`. Will be removed in a future release. + + SWE Common 3 / OGC CS API only carry one display string per system + (SML's ``label``, GeoJSON's ``properties.name``). The wrapper's + prior `name` field was always set to the same value as `label`. + Use `self.label` directly going forward. + """ + import warnings + warnings.warn( + "`System.name` is deprecated; use `.label` instead.", + DeprecationWarning, stacklevel=2, + ) + return self.label + + @name.setter + def name(self, value: str) -> None: + import warnings + warnings.warn( + "Setting `System.name` is deprecated; set `.label` instead.", + DeprecationWarning, stacklevel=2, + ) + self.label = value + + @staticmethod + def _pick_datastream_schema_format(formats: list[str]): + """Choose an ``obsFormat`` for the schema fetch, plus the parser + that knows how to validate the response. + + Preference order: SWE+JSON (textual, easiest to inspect) → + SWE+binary (the only choice for video/blob datastreams that + don't advertise SWE+JSON, e.g. Axis cameras' ``video1``). Returns + ``(None, None)`` if neither is advertised, so the caller can + skip the fetch with a warning instead of crashing. + """ + if formats is None: + return None, None + if "application/swe+json" in formats: + return ("application/swe+json", + SWEDatastreamRecordSchema.from_swejson_dict) + if "application/swe+proto" in formats: + return ("application/swe+proto", + SWEProtobufDatastreamRecordSchema.from_sweproto_dict) + if "application/swe+flatbuffers" in formats: + return ("application/swe+flatbuffers", + SWEFlatBuffersDatastreamRecordSchema.from_sweflatbuffers_dict) + if "application/swe+binary" in formats: + return ("application/swe+binary", + SWEBinaryDatastreamRecordSchema.from_swebinary_dict) + return None, None + + def discover_datastreams(self) -> list[Datastream]: + """GET ``/systems/{id}/datastreams`` and instantiate `Datastream` + objects for every entry. New datastreams are appended to + ``self.datastreams`` and also returned. + + For each discovered datastream we additionally fetch its record + schema (``GET /datastreams/{id}/schema?obsFormat=…``) and cache it + on ``_underlying_resource.record_schema``. The schema variant is + chosen from the datastream's advertised ``formats`` list: + ``application/swe+json`` is preferred when available (parsed as + `SWEDatastreamRecordSchema`); otherwise ``application/swe+binary`` + is used (parsed as `SWEBinaryDatastreamRecordSchema`). Datastreams + like Axis camera ``video1`` outputs advertise *only* the binary + variant — without this fallback every video datastream would land + without a schema. The CS API listing endpoint omits the inner + schema, so without this step every discovered datastream would be + missing the schema callers need for observation construction or + cross-node sync. A failure on a single datastream's schema fetch + is downgraded to a warning so it doesn't poison the whole call. + """ + api = self._parent_node.get_api_helper() + res = api.get_resource(APIResourceTypes.SYSTEM, self._resource_id, + APIResourceTypes.DATASTREAM) + datastream_json = res.json()['items'] + datastreams = [] + + for ds in datastream_json: + datastream_objs = DatastreamResource.model_validate(ds, by_alias=True) + new_ds = Datastream(self._parent_node, datastream_objs) + obs_format, parser = self._pick_datastream_schema_format( + datastream_objs.formats) + if obs_format is None: + msg = ( + f"Datastream {datastream_objs.ds_id} advertises no " + f"supported schema format (have: {datastream_objs.formats}); " + "skipping schema fetch." + ) + logging.warning(msg) + warnings.warn(msg, SchemaFetchWarning, stacklevel=2) + else: + try: + schema_resp = api.get_resource( + APIResourceTypes.DATASTREAM, datastream_objs.ds_id, + APIResourceTypes.SCHEMA, + params={'obsFormat': obs_format}, + ) + schema_resp.raise_for_status() + new_ds._underlying_resource.record_schema = parser(schema_resp.json()) + except Exception as e: + msg = ( + f"Failed to fetch {obs_format} schema for datastream " + f"{datastream_objs.ds_id}: {type(e).__name__}: {e}" + ) + logging.error(msg, exc_info=True) + warnings.warn(msg, SchemaFetchWarning, stacklevel=2) + datastreams.append(new_ds) + + if not [ds.get_underlying_resource() != datastream_objs for ds in self.datastreams]: + self.datastreams.append(new_ds) + + return datastreams + + def discover_controlstreams(self) -> list[ControlStream]: + """GET ``/systems/{id}/controlstreams`` and instantiate `ControlStream` + objects for every entry. New control streams are appended to + ``self.control_channels`` and also returned. + + For each discovered control stream we additionally fetch the + command schema (``GET /controlstreams/{id}/schema?f=json``, + which OSH returns as ``application/json`` with a + ``parametersSchema`` SWE Common component) and cache it on + ``_underlying_resource.command_schema`` as a `JSONCommandSchema`. + ``f=json`` is the OGC API standard format-selector and pins the + response shape to the JSON variant — without it the server + default could change. The CS API listing endpoint omits the + inner schema, so without this step every discovered control + stream would be missing the schema callers need for command + construction or cross-node sync. A failure on a single control + stream's schema fetch is downgraded to a warning so it doesn't + poison the whole call. + """ + api = self._parent_node.get_api_helper() + res = api.get_resource(APIResourceTypes.SYSTEM, self._resource_id, + APIResourceTypes.CONTROL_CHANNEL) + controlstream_json = res.json()['items'] + controlstreams = [] + + for cs_json in controlstream_json: + controlstream_objs = ControlStreamResource.model_validate(cs_json) + new_cs = ControlStream(self._parent_node, controlstream_objs) + try: + schema_resp = api.get_resource( + APIResourceTypes.CONTROL_CHANNEL, controlstream_objs.cs_id, + APIResourceTypes.SCHEMA, + params={'f': 'json'}, + ) + schema_resp.raise_for_status() + new_cs._underlying_resource.command_schema = ( + JSONCommandSchema.from_json_dict(schema_resp.json()) + ) + except Exception as e: + msg = ( + f"Failed to fetch command schema for control stream " + f"{controlstream_objs.cs_id}: {type(e).__name__}: {e}" + ) + logging.error(msg, exc_info=True) + warnings.warn(msg, SchemaFetchWarning, stacklevel=2) + controlstreams.append(new_cs) + + if not [cs.get_underlying_resource() != controlstream_objs for cs in self.control_channels]: + self.control_channels.append(new_cs) + + return controlstreams + + @classmethod + def _construct_from_resource(cls, system_resource: SystemResource, parent_node: Node) -> "System": + """Build a `System` from a parsed `SystemResource`. Internal helper + shared by `from_csapi_dict` / `from_smljson_dict` / `from_geojson_dict` + and the deprecated `from_system_resource`. + """ + # exclude_none avoids triggering TimePeriod.ser_model on None-valued + # optional time fields (it does `str(self.start)` unconditionally). + other_props = system_resource.model_dump(exclude_none=True) + # GeoJSON form carries `properties.name`/`properties.uid`; SML form + # has `label`/`uid` directly on the resource. Both wire shapes + # carry exactly one display string, mapped to `System.label`. + if other_props.get('properties'): + props = other_props['properties'] + new_system = cls(label=props.get('name'), urn=props.get('uid'), + resource_id=system_resource.system_id, parent_node=parent_node) + else: + new_system = cls(label=system_resource.label, urn=system_resource.uid, + resource_id=system_resource.system_id, parent_node=parent_node) + + new_system.set_system_resource(system_resource) + return new_system + + @classmethod + def from_resource(cls, system_resource: SystemResource, parent_node: Node) -> "System": + """Build a `System` from an already-parsed `SystemResource`. + + Mirror of `Datastream.__init__(parent_node=, datastream_resource=)` + and `ControlStream.__init__(node=, controlstream_resource=)` — + provides the same "I have a parsed pydantic resource model in + memory and want a wrapper attached to a node" entry point for + Systems, whose constructor takes individual fields rather than a + full resource model. + + Handles both wire shapes that round-trip through `SystemResource`: + the GeoJSON form (with a ``properties`` block carrying + ``name``/``uid``) and the SML form (``label``/``uid`` directly on + the resource). Source of the resource doesn't matter — built + locally, validated from `from_smljson_dict` / `from_geojson_dict` + / `from_csapi_dict`, returned by some other library, etc. + + :param system_resource: A populated `SystemResource` instance. + :param parent_node: The `Node` the new `System` will attach to. + :return: A `System` wrapper bound to ``parent_node`` with + ``_underlying_resource`` set to ``system_resource``. + """ + return cls._construct_from_resource(system_resource, parent_node) + + @staticmethod + def from_system_resource(system_resource: SystemResource, parent_node: Node) -> System: + """Build a `System` from an already-parsed `SystemResource`. + + .. deprecated:: 0.5.1 + Use :meth:`System.from_resource` instead — same behavior, more + consistent name with other wrappers' resource-taking factories. + + Handles both shapes the OSH server emits: the GeoJSON form (with a + ``properties`` block carrying ``name``/``uid``) and the SML form + (``label``/``uid`` directly on the resource). + """ + warnings.warn("System.from_system_resource is deprecated; use System.from_resource instead " + "(then dump it to a dict if you need wire JSON).", DeprecationWarning, stacklevel=2, ) + return System._construct_from_resource(system_resource, parent_node) + + def to_system_resource(self) -> SystemResource: + """Render this `System` as a `SystemResource` pydantic model + suitable for POSTing to the server. + + When this wrapper already carries an ``_underlying_resource`` + (e.g. populated by ``from_csapi_dict``, ``set_system_resource``, + or a prior ``retrieve_resource`` call), all of its fields are + preserved into a deep copy — so cross-node sync, partial + updates, and re-POSTs round-trip everything the source carried, + not just ``uniqueId`` / ``label`` / a hardcoded + ``PhysicalSystem`` type. Currently-attached datastreams are + always reflected into ``outputs`` so newly-added children come + along. + + When no underlying resource is present (i.e. during this + wrapper's own ``__init__``), a thin shell is built from + wrapper attrs and the SML type defaults to ``PhysicalSystem``. + """ + underlying = getattr(self, '_underlying_resource', None) + if underlying is not None: + resource = underlying.model_copy(deep=True) + # Pick up any wrapper-side updates the user made directly + # on the System (the wrapper doesn't proxy these into the + # resource on assignment). + if self.urn and not resource.uid: + resource.uid = self.urn + if self.label and not resource.label: + resource.label = self.label + else: + resource = SystemResource(uid=self.urn, label=self.label, + feature_type='PhysicalSystem') + if self.datastreams: + resource.outputs = [ds.get_underlying_resource() for ds in self.datastreams] + return resource + + def set_system_resource(self, sys_resource: SystemResource): + """Replace the underlying `SystemResource` model.""" + self._underlying_resource = sys_resource + + def get_system_resource(self) -> SystemResource: + """Return the underlying `SystemResource` model.""" + return self._underlying_resource + + def add_insert_datastream(self, datastream_schema: DatastreamResource): + """Adds a datastream to the system while also inserting it into the + system's parent node via HTTP POST. + + :param datastream_schema: DataRecordSchema to be used to define the + datastream. Must carry a ``name`` matching NameToken + (``^[A-Za-z][A-Za-z0-9_\\-]*$``); SWE Common 3 wraps + DataStream.elementType in SoftNamedProperty, so the root + component requires a name. + :return: + """ + api = self._parent_node.get_api_helper() + res = api.create_resource(APIResourceTypes.DATASTREAM, + datastream_schema.model_dump_json(by_alias=True, exclude_none=True), + req_headers={'Content-Type': ContentTypes.JSON.value}, + parent_res_id=self._resource_id) + + if res.ok: + datastream_id = res.headers['Location'].split('/')[-1] + datastream_schema.ds_id = datastream_id + else: + raise Exception( + f'Failed to create datastream {datastream_schema.name!r}: ' + f'HTTP {res.status_code} — {res.text}' + ) + + new_ds = Datastream(self._parent_node, datastream_schema) + new_ds.set_parent_resource_id(self._underlying_resource.system_id) + self.datastreams.append(new_ds) + return new_ds + + def add_insert_controlstream(self, controlstream_resource: ControlStreamResource) -> ControlStream: + """Adds a control stream to the system while also inserting it into + the system's parent node via HTTP POST. + + Mirrors `add_insert_datastream`: caller assembles the full + `ControlStreamResource` (including the embedded `command_schema`) + and this method posts it to ``/systems/{id}/controlstreams``, + captures the new resource ID from the ``Location`` header, and + returns a wrapped `ControlStream`. + + For the embedded `command_schema`, prefer + `JSONCommandSchema` (`commandFormat: application/json` with a + ``parametersSchema``). It matches what OSH returns from + ``GET /controlstreams/{id}/schema?f=json`` (the form + ``discover_controlstreams`` parses), keeps round-trip sync + symmetric, and avoids the SWE+JSON ``encoding``-omission + deviation documented in ``docs/osh_spec_deviations.md`` §1. + `SWEJSONCommandSchema` (``application/swe+json`` with + ``recordSchema`` plus ``encoding``) is also accepted for + spec-strict scenarios. + + :param controlstream_resource: A fully-built + `ControlStreamResource` carrying ``name``, ``input_name``, + and ``command_schema``. + :return: ControlStream object added to the system. + """ + api = self._parent_node.get_api_helper() + res = api.create_resource( + APIResourceTypes.CONTROL_CHANNEL, + controlstream_resource.model_dump_json(by_alias=True, exclude_none=True), + req_headers={'Content-Type': ContentTypes.JSON.value}, + parent_res_id=self._resource_id, + ) + + if res.ok: + cs_id = res.headers['Location'].split('/')[-1] + controlstream_resource.cs_id = cs_id + else: + raise Exception( + f'Failed to create control stream {controlstream_resource.name!r}: ' + f'HTTP {res.status_code} — {res.text}' + ) + + new_cs = ControlStream(node=self._parent_node, controlstream_resource=controlstream_resource) + new_cs.set_parent_resource_id(self._underlying_resource.system_id) + self.control_channels.append(new_cs) + return new_cs + + def add_and_insert_control_stream(self, control_stream_record_schema: DataRecordSchema, input_name: str = None, + valid_time: TimePeriod = None, + command_format: str = "application/json") -> ControlStream: + """Accepts a DataRecordSchema and creates a ControlStreamResource + with the matching command-schema variant, then POSTs it to the + parent node. + + Per CS API Part 2 §16.x, command schemas come in two wire forms: + + - ``application/json`` → `JSONCommandSchema` carrying + `parametersSchema` (the SWE Common component); no `encoding`. + **This is the default.** It matches what OSH returns from + ``GET /controlstreams/{id}/schema?f=json`` (the form + ``discover_controlstreams`` parses), keeps round-trip sync + symmetric, and avoids the SWE+JSON ``encoding``-omission + deviation documented in ``docs/osh_spec_deviations.md`` §1. + - ``application/swe+json`` → `SWEJSONCommandSchema` carrying + `recordSchema` (the SWE Common component) and `encoding` + (`JSONEncoding`). Spec-canonical; pass + ``command_format='application/swe+json'`` to opt in. + + :param control_stream_record_schema: DataRecordSchema to wrap. + Must carry a ``name`` matching NameToken + (``^[A-Za-z][A-Za-z0-9_\\-]*$``); the schema is the root + named component required by both command-schema variants. + :param input_name: Name of the input. If None, the schema label + is lowercased and whitespace-stripped. + :param valid_time: Optional `TimePeriod`; defaults to + ``[now, now + 1 year]``. + :param command_format: ``"application/json"`` (default) or + ``"application/swe+json"``. Anything else raises + ``ValueError``. + :return: ControlStream object added to the system. + """ + input_name_checked = input_name if input_name is not None else control_stream_record_schema.label.lower().replace( + ' ', '') + + now = datetime.datetime.now() + future_time = now.replace(year=now.year + 1) + future_str = future_time.strftime("%Y-%m-%dT%H:%M:%SZ") + + valid_time_checked = valid_time if valid_time else TimePeriod(start=TimeInstant.now_as_time_instant(), + end=TimeInstant( + utc_time=TimeUtils.to_utc_time(future_str))) + + if command_format == "application/swe+json": + command_schema = SWEJSONCommandSchema( + command_format="application/swe+json", + record_schema=control_stream_record_schema, + encoding=JSONEncoding(), + ) + elif command_format == "application/json": + command_schema = JSONCommandSchema( + command_format="application/json", + params_schema=control_stream_record_schema, + ) + else: + raise ValueError( + f"Unsupported command_format: {command_format!r}. " + f"Expected 'application/swe+json' or 'application/json'." + ) + + control_stream_resource = ControlStreamResource(name=control_stream_record_schema.label, + input_name=input_name_checked, command_schema=command_schema, + validTime=valid_time_checked) + api = self._parent_node.get_api_helper() + res = api.create_resource(APIResourceTypes.CONTROL_CHANNEL, + control_stream_resource.model_dump_json(by_alias=True, exclude_none=True), + req_headers={'Content-Type': 'application/json'}, parent_res_id=self._resource_id) + + if res.ok: + control_channel_id = res.headers['Location'].split('/')[-1] + control_stream_resource.cs_id = control_channel_id + else: + raise Exception( + f'Failed to create control stream {control_stream_resource.name!r}: ' + f'HTTP {res.status_code} — {res.text}' + ) + + new_cs = ControlStream(node=self._parent_node, controlstream_resource=control_stream_resource) + new_cs.set_parent_resource_id(self._underlying_resource.system_id) + self.control_channels.append(new_cs) + return new_cs + + def insert_self(self): + """POST this system to the server (Content-Type + ``application/sml+json``) and capture the new resource ID from + the ``Location`` response header. + + Server-assigned fields (``id``, ``links``) are stripped from + the body before POST so a re-POSTed (e.g. cross-node-synced) + system doesn't leak the source server's identifier or links to + the destination — the destination assigns its own. + """ + body_resource = self.to_system_resource().model_copy(deep=True) + body_resource.system_id = None + body_resource.links = None + res = self._parent_node.get_api_helper().create_resource( + APIResourceTypes.SYSTEM, + body_resource.model_dump_json(by_alias=True, exclude_none=True), + req_headers={'Content-Type': 'application/sml+json'}) + + if res.ok: + location = res.headers['Location'] + sys_id = location.split('/')[-1] + self._resource_id = sys_id + if self._underlying_resource is not None: + self._underlying_resource.system_id = sys_id + + def retrieve_resource(self): + """GET ``/systems/{id}`` and refresh the underlying `SystemResource`. + Returns ``None`` either way (kept for API symmetry). + """ + if self._resource_id is None: + return None + res = self._parent_node.get_api_helper().retrieve_resource(res_type=APIResourceTypes.SYSTEM, + res_id=self._resource_id) + if res.ok: + system_json = res.json() + system_resource = SystemResource.model_validate(system_json) + self._underlying_resource = system_resource + return None + + def to_storage_dict(self) -> dict: + """Return a JSON-safe snapshot of this system, its child datastreams / + control streams, and the dumped underlying `SystemResource`, for + OSHConnect's persistence layer. + + Not a CS API server-shaped payload — the ``underlying_resource`` + block is the only piece that matches the CS API system shape. + """ + data = super().to_storage_dict() + data["label"] = getattr(self, "label", None) + data["urn"] = getattr(self, "urn", None) + data["description"] = getattr(self, "description", None) + datastreams = getattr(self, "datastreams", None) + if datastreams is not None: + data["datastreams"] = [ds.to_storage_dict() for ds in datastreams] + else: + data["datastreams"] = None + control_channels = getattr(self, "control_channels", None) + if control_channels is not None: + data["control_channels"] = [cc.to_storage_dict() for cc in control_channels] + else: + data["control_channels"] = None + underlying = getattr(self, "_underlying_resource", None) + if underlying is not None: + dump = getattr(underlying, 'model_dump', None) + if callable(dump): + data["underlying_resource"] = underlying.model_dump(by_alias=True, exclude_none=True, mode='json') + elif hasattr(underlying, 'to_dict'): + data["underlying_resource"] = underlying.to_dict() + else: + data["underlying_resource"] = str(underlying) + else: + data["underlying_resource"] = None + # Remove any 'resource' key if present + data.pop("resource", None) + return data + + @classmethod + def from_storage_dict(cls, data: dict, node: 'Node') -> 'System': + """Build a `System` from a dict produced by `to_storage_dict`. + + Expects ``label``, ``urn``, optional ``description`` / + ``resource_id``, and optional ``datastreams`` / ``control_channels`` + / ``underlying_resource`` blocks. The embedded + ``underlying_resource`` is parsed via `SystemResource.model_validate`, + so that nested block can also be a CS API server response body. + + For backwards compatibility, ``data["name"]`` is accepted as a + legacy alias for ``label`` if ``label`` is missing — older + snapshots written before the `name`/`label` consolidation + still load. + + :param data: Source dict. + :param node: Parent `Node` the rebuilt system attaches to. + """ + label = data.get("label") or data.get("name") + obj = cls( + label=label, urn=data["urn"], parent_node=node, + description=data.get("description"), resource_id=data.get("resource_id")) + obj._id = uuid.UUID(data["id"]) + obj.datastreams = [Datastream.from_storage_dict(ds, node) for ds in data.get("datastreams", [])] + obj.control_channels = [ControlStream.from_storage_dict(cc, node) for cc in data.get("control_channels", [])] + underlying = data.get("underlying_resource") + obj._underlying_resource = SystemResource.model_validate(underlying) if underlying else None + return obj diff --git a/src/oshconnect/schema_datamodels.py b/src/oshconnect/schema_datamodels.py index a1ff338..2bab7ca 100644 --- a/src/oshconnect/schema_datamodels.py +++ b/src/oshconnect/schema_datamodels.py @@ -7,15 +7,29 @@ from __future__ import annotations from datetime import datetime -from typing import Union, List +from typing import Annotated, Union, List, Literal -from pydantic import BaseModel, Field, SerializeAsAny, field_validator, model_validator, HttpUrl, ConfigDict +from pydantic import BaseModel, Field, model_validator, HttpUrl, ConfigDict from .api_utils import Link, URI -from .csapi4py.constants import ObservationFormat -from .encoding import Encoding +from .encoding import BinaryEncoding, FlatBuffersEncoding, JSONEncoding, ProtobufEncoding from .geometry import Geometry from .swe_components import AnyComponent, check_named +from .timemanagement import TimeInstant + + +def _now_iso8601_z() -> str: + """Per-call default for ``CommandJSON.issue_time``: a UTC timestamp with + trailing ``Z`` (CS API Part 2 / SWE Common 3 expect a valid ISO8601 + with zone info — OSH 400s on the bare ``datetime.now().isoformat()`` + form because it has no zone designator).""" + return TimeInstant.now_as_time_instant().get_iso_time() + + +def _dump_csapi(model: BaseModel) -> dict: + """Internal: canonical CS API serialization (alias keys, exclude None, JSON-mode).""" + return model.model_dump(by_alias=True, exclude_none=True, mode='json') + """ In many of the top level resource models there is a "schema" field of some description. These models are meant to ease @@ -29,9 +43,21 @@ class CommandJSON(BaseModel): """ model_config = ConfigDict(populate_by_name=True) control_id: str = Field(None, serialization_alias="control@id") - issue_time: Union[str, float] = Field(datetime.now().isoformat(), serialization_alias="issueTime") + issue_time: Union[str, float] = Field(default_factory=_now_iso8601_z, + serialization_alias="issueTime") sender: str = Field(None) - params: Union[dict, list, int, float, str] = Field(None) + # CS API Part 2 — and OSH — call this field ``parameters`` on the wire. + # ``populate_by_name=True`` keeps the Python attribute readable as ``params``. + params: Union[dict, list, int, float, str] = Field(None, alias="parameters") + + def to_csapi_dict(self) -> dict: + """Render as the CS API `application/json` command body.""" + return _dump_csapi(self) + + @classmethod + def from_csapi_dict(cls, data: dict) -> "CommandJSON": + """Build from a CS API command JSON dict.""" + return cls.model_validate(data) class CommandSchema(BaseModel): @@ -49,8 +75,16 @@ class SWEJSONCommandSchema(CommandSchema): """ model_config = ConfigDict(populate_by_name=True) - command_format: str = Field("application/swe+json", alias='commandFormat') - encoding: SerializeAsAny[Encoding] = Field(...) + # Literal pin powers the discriminated `AnyCommandSchema` union below + # and removes the need for a runtime field_validator. + command_format: Literal["application/swe+json"] = Field( + "application/swe+json", alias='commandFormat') + # Concrete subclass instead of `SerializeAsAny[Encoding]` — `JSONEncoding` + # is the only Encoding type used in practice, and a concrete type + # serializes deterministically without `SerializeAsAny`. If/when more + # encoding types arrive, migrate this to a discriminated Union on + # `Encoding.type`. + encoding: JSONEncoding = Field(...) record_schema: AnyComponent = Field(..., alias='recordSchema') @model_validator(mode="after") @@ -58,6 +92,15 @@ def _root_record_schema_requires_name(self): check_named(self.record_schema, "SWEJSONCommandSchema.recordSchema") return self + def to_swejson_dict(self) -> dict: + """Render as an `application/swe+json` command-schema document.""" + return _dump_csapi(self) + + @classmethod + def from_swejson_dict(cls, data: dict) -> "SWEJSONCommandSchema": + """Build from an `application/swe+json` command-schema dict.""" + return cls.model_validate(data, by_alias=True) + class JSONCommandSchema(CommandSchema): """ @@ -65,7 +108,7 @@ class JSONCommandSchema(CommandSchema): """ model_config = ConfigDict(populate_by_name=True) - command_format: str = Field("application/json", alias='commandFormat') + command_format: Literal["application/json"] = Field("application/json", alias='commandFormat') params_schema: AnyComponent = Field(..., alias='parametersSchema') result_schema: AnyComponent = Field(None, alias='resultSchema') feasibility_schema: AnyComponent = Field(None, alias='feasibilityResultSchema') @@ -79,6 +122,15 @@ def _root_schemas_require_name(self): check_named(self.feasibility_schema, "JSONCommandSchema.feasibilityResultSchema") return self + def to_json_dict(self) -> dict: + """Render as an `application/json` command-schema document.""" + return _dump_csapi(self) + + @classmethod + def from_json_dict(cls, data: dict) -> "JSONCommandSchema": + """Build from an `application/json` command-schema dict.""" + return cls.model_validate(data, by_alias=True) + class DatastreamRecordSchema(BaseModel): """ @@ -95,26 +147,164 @@ class DatastreamRecordSchema(BaseModel): # docs/osh_spec_deviations.md (swe-json-missing-encoding). class SWEDatastreamRecordSchema(DatastreamRecordSchema): model_config = ConfigDict(populate_by_name=True) - encoding: SerializeAsAny[Encoding] = Field(None) + # Multi-Literal acts as the discriminator value(s) for AnyDatastreamRecordSchema + # below. Replaces the previous runtime field_validator. + # + # Note: `application/swe+binary` is NOT included here — it has a distinct + # `encoding` shape (`BinaryEncoding`, not `JSONEncoding`) and gets its own + # class (`SWEBinaryDatastreamRecordSchema`) so the discriminated union can + # dispatch on `obsFormat` without runtime branching on the encoding type. + obs_format: Literal[ + "application/swe+json", + "application/swe+csv", + "application/swe+text", + ] = Field(..., alias='obsFormat') + encoding: JSONEncoding = Field(None) + record_schema: AnyComponent = Field(..., alias='recordSchema') + + @model_validator(mode="after") + def _root_record_schema_requires_name(self): + check_named(self.record_schema, "SWEDatastreamRecordSchema.recordSchema") + return self + + def to_swejson_dict(self) -> dict: + """Render as an `application/swe+json` datastream-schema document.""" + return _dump_csapi(self) + + @classmethod + def from_swejson_dict(cls, data: dict) -> "SWEDatastreamRecordSchema": + """Build from an `application/swe+json` datastream-schema dict + (e.g., a CS API ``/datastreams/{id}/schema`` response in SWE form).""" + return cls.model_validate(data, by_alias=True) + + +class SWEBinaryDatastreamRecordSchema(DatastreamRecordSchema): + """Datastream observation schema for `application/swe+binary`. + + Split from `SWEDatastreamRecordSchema` because the encoding block is a + `BinaryEncoding` (with a `members` list mapping component refs to + `dataType` / `compression`), not a `JSONEncoding`. The `recordSchema` + side mirrors the SWE+JSON form — it describes the *semantic* shape + of the record. The `recordEncoding` side describes the *wire* shape, + overriding the semantic shape where needed (e.g. a `DataArray` in + the recordSchema may be replaced by a single `Block` member with + ``compression="H264"`` on the wire, as Axis cameras do for video). + + Use ``oshconnect.swe_binary.SWEBinaryCodec(schema)`` to encode dicts + to bytes and decode bytes back to dicts. + """ + model_config = ConfigDict(populate_by_name=True) + + obs_format: Literal["application/swe+binary"] = Field( + "application/swe+binary", alias='obsFormat') record_schema: AnyComponent = Field(..., alias='recordSchema') + # OSH emits ``recordEncoding`` for the binary variant; the JSON-family + # variant calls the same slot ``encoding``. Accept either via alias. + record_encoding: BinaryEncoding = Field(..., alias='recordEncoding') + + @model_validator(mode="after") + def _root_record_schema_requires_name(self): + check_named(self.record_schema, "SWEBinaryDatastreamRecordSchema.recordSchema") + return self + + def to_swebinary_dict(self) -> dict: + """Render as an `application/swe+binary` datastream-schema document.""" + return _dump_csapi(self) - @field_validator('obs_format') @classmethod - def check_check_obs_format(cls, v): - if v not in [ObservationFormat.SWE_JSON.value, ObservationFormat.SWE_CSV.value, - ObservationFormat.SWE_TEXT.value, ObservationFormat.SWE_BINARY.value]: - raise ValueError('obsFormat must be on of the SWE formats') - return v + def from_swebinary_dict(cls, data: dict) -> "SWEBinaryDatastreamRecordSchema": + """Build from an `application/swe+binary` datastream-schema dict + (a CS API ``/datastreams/{id}/schema?obsFormat=application/swe+binary`` + response body).""" + return cls.model_validate(data, by_alias=True) + + +class SWEProtobufDatastreamRecordSchema(DatastreamRecordSchema): + """Datastream observation schema for ``application/swe+proto``. + + The on-wire bytes are a Protobuf-serialized SWE Common 3 message, + using the schemas from + https://github.com/tipatterson-dev/BinaryEncodings. Like the SWE+JSON + and SWE+Binary variants, the SDK still carries the `recordSchema` + (a SWE Common `AnyComponent` tree) so callers can introspect the + field structure without parsing the protobuf descriptor. + + The codec lives in ``oshconnect.swe_protobuf.SWEProtobufCodec``. It + walks the `recordSchema` tree at runtime to translate between + `dict` records (the OSHConnect-side representation) and a populated + `DataRecord` protobuf message (the wire representation). + """ + model_config = ConfigDict(populate_by_name=True) + + obs_format: Literal["application/swe+proto"] = Field( + "application/swe+proto", alias='obsFormat') + record_schema: AnyComponent = Field(..., alias='recordSchema') + # `recordEncoding` is optional: the wire layout is fully defined by the + # protobuf descriptor, so the marker mostly carries `type` for downstream + # tooling that wants to dump the schema round-trippable. + record_encoding: ProtobufEncoding = Field( + default_factory=ProtobufEncoding, alias='recordEncoding') @model_validator(mode="after") def _root_record_schema_requires_name(self): - check_named(self.record_schema, "SWEDatastreamRecordSchema.recordSchema") + check_named(self.record_schema, "SWEProtobufDatastreamRecordSchema.recordSchema") return self + def to_sweproto_dict(self) -> dict: + """Render as an `application/swe+proto` datastream-schema document.""" + return _dump_csapi(self) + + @classmethod + def from_sweproto_dict(cls, data: dict) -> "SWEProtobufDatastreamRecordSchema": + """Build from an `application/swe+proto` datastream-schema dict.""" + return cls.model_validate(data, by_alias=True) -class JSONDatastreamRecordSchema(DatastreamRecordSchema): - """Datastream observation schema for the JSON media types - (`application/json`, `application/om+json`). + +class SWEFlatBuffersDatastreamRecordSchema(DatastreamRecordSchema): + """Datastream observation schema for ``application/swe+flatbuffers``. + + Mirrors `SWEProtobufDatastreamRecordSchema`. The wire format is a + FlatBuffers-serialized SWE Common 3 message; the codec lives in + ``oshconnect.swe_flatbuffers.SWEFlatBuffersCodec``. + + .. warning:: + + The FlatBuffers codec is not currently functional — `flatc + --python` does not yet support vectors-of-unions, which the + SWE Common 3 schema uses for `BinaryEncoding.members`. The + schema class is provided so the SDK can already parse and + round-trip schemas that name this format; calling + ``SWEFlatBuffersCodec.encode``/``decode`` raises + `NotImplementedError`. See + ``docs/osh_spec_deviations.md`` (flatc-python-vector-of-union). + """ + model_config = ConfigDict(populate_by_name=True) + + obs_format: Literal["application/swe+flatbuffers"] = Field( + "application/swe+flatbuffers", alias='obsFormat') + record_schema: AnyComponent = Field(..., alias='recordSchema') + record_encoding: FlatBuffersEncoding = Field( + default_factory=FlatBuffersEncoding, alias='recordEncoding') + + @model_validator(mode="after") + def _root_record_schema_requires_name(self): + check_named(self.record_schema, "SWEFlatBuffersDatastreamRecordSchema.recordSchema") + return self + + def to_sweflatbuffers_dict(self) -> dict: + """Render as an `application/swe+flatbuffers` datastream-schema document.""" + return _dump_csapi(self) + + @classmethod + def from_sweflatbuffers_dict(cls, data: dict) -> "SWEFlatBuffersDatastreamRecordSchema": + """Build from an `application/swe+flatbuffers` datastream-schema dict.""" + return cls.model_validate(data, by_alias=True) + + +class OMJSONDatastreamRecordSchema(DatastreamRecordSchema): + """Datastream observation schema for the OM+JSON media type + (`application/om+json`, also accepts `application/json` as a synonym + on parse since OSH treats them equivalently for datastream schemas). Per CS API Part 2 §16.1.4, this form does not carry a SWE `encoding` block; structure is fully described by `resultSchema` (inline result) @@ -122,41 +312,119 @@ class JSONDatastreamRecordSchema(DatastreamRecordSchema): """ model_config = ConfigDict(populate_by_name=True) - obs_format: str = Field(ObservationFormat.JSON.value, alias='obsFormat') + # Multi-Literal — both wire forms are spec-equivalent for OM+JSON. + obs_format: Literal[ + "application/om+json", + "application/json", + ] = Field("application/om+json", alias='obsFormat') result_schema: AnyComponent = Field(None, alias='resultSchema') parameters_schema: AnyComponent = Field(None, alias='parametersSchema') result_link: dict = Field(None, alias='resultLink') - @field_validator('obs_format') - @classmethod - def _check_obs_format(cls, v): - if v not in (ObservationFormat.JSON.value, "application/json"): - raise ValueError( - f"obsFormat must be 'application/json' or '{ObservationFormat.JSON.value}'" - ) - return v - @model_validator(mode="after") def _root_schemas_require_name(self): if self.result_schema is not None: - check_named(self.result_schema, "JSONDatastreamRecordSchema.resultSchema") + check_named(self.result_schema, "OMJSONDatastreamRecordSchema.resultSchema") if self.parameters_schema is not None: - check_named(self.parameters_schema, "JSONDatastreamRecordSchema.parametersSchema") + check_named(self.parameters_schema, "OMJSONDatastreamRecordSchema.parametersSchema") return self + def to_omjson_dict(self) -> dict: + """Render as an `application/om+json` datastream-schema document.""" + return _dump_csapi(self) + + @classmethod + def from_omjson_dict(cls, data: dict) -> "OMJSONDatastreamRecordSchema": + """Build from an `application/om+json` (or `application/json`) + datastream-schema dict (e.g., a CS API ``/datastreams/{id}/schema`` + response in OM+JSON form).""" + return cls.model_validate(data, by_alias=True) + + +class LogicalProperty(BaseModel): + """One entry in `LogicalDatastreamRecordSchema.properties`. + + The logical schema is OSH's JSON-Schema-flavored representation of a + SWE Common DataRecord. Each property is a JSON Schema field with + OGC extension keywords (`x-ogc-definition`, `x-ogc-refFrame`, + `x-ogc-unit`, `x-ogc-axis`) that carry the SWE Common metadata. + + Permissive: ``extra='allow'`` accepts JSON Schema fields we haven't + modeled (e.g. ``description``, ``default``, ``minimum``, ``maximum``, + nested ``items`` for arrays). + """ + model_config = ConfigDict(populate_by_name=True, extra='allow') + + title: str = Field(None) + type: str = Field(...) # "string" | "number" | "integer" | "boolean" | "object" | "array" + format: str = Field(None) # e.g. "date-time" + enum: list = Field(None) + items: dict = Field(None) # for type="array" + properties: dict = Field(None) # for type="object" (nested) + + # OGC SWE Common extensions (hyphenated keys → aliased) + ogc_definition: str = Field(None, alias='x-ogc-definition') + ogc_ref_frame: str = Field(None, alias='x-ogc-refFrame') + ogc_unit: str = Field(None, alias='x-ogc-unit') + ogc_axis: str = Field(None, alias='x-ogc-axis') + + +class LogicalDatastreamRecordSchema(BaseModel): + """Logical schema document — OSH's `obsFormat=logical` representation. + + Returned by ``GET /datastreams/{id}/schema?obsFormat=logical``. Distinct + from `SWEDatastreamRecordSchema` and `OMJSONDatastreamRecordSchema`: + + - No ``obsFormat`` envelope field + - No ``recordSchema`` wrapper — the schema is the document + - JSON Schema flavor (``type: "object"`` + ``properties``) instead of + a SWE Common AnyComponent tree + - Each property carries SWE Common metadata via ``x-ogc-*`` extension + keywords + + OSH-specific (not in the OGC CS API spec) but useful for tooling that + speaks JSON Schema natively. Permissive (``extra='allow'``) so future + JSON Schema fields don't break parsing. + """ + model_config = ConfigDict(populate_by_name=True, extra='allow') + + type: str = Field(...) # always "object" for OSH datastream schemas + title: str = Field(None) + properties: dict[str, LogicalProperty] = Field(...) + required: list[str] = Field(None) + + def to_logical_dict(self) -> dict: + """Render as an OSH `obsFormat=logical` JSON Schema dict.""" + return _dump_csapi(self) + + @classmethod + def from_logical_dict(cls, data: dict) -> "LogicalDatastreamRecordSchema": + """Build from a logical schema dict (e.g., a CS API + ``/datastreams/{id}/schema?obsFormat=logical`` response body).""" + return cls.model_validate(data, by_alias=True) + class ObservationOMJSONInline(BaseModel): """ A class to represent an observation in OM-JSON format """ model_config = ConfigDict(populate_by_name=True) - datastream_id: str = Field(None, serialization_alias="datastream@id") - foi_id: str = Field(None, serialization_alias="foi@id") - phenomenon_time: str = Field(None, serialization_alias="phenomenonTime") - result_time: str = Field(datetime.now().isoformat(), serialization_alias="resultTime") + datastream_id: str = Field(None, alias="datastream@id") + foi_id: str = Field(None, alias="foi@id") + phenomenon_time: str = Field(None, alias="phenomenonTime") + result_time: str = Field(datetime.now().isoformat(), alias="resultTime") parameters: dict = Field(None) result: Union[int, float, str, dict, list] = Field(...) - result_links: List[Link] = Field(None, serialization_alias="result@links") + result_links: List[Link] = Field(None, alias="result@links") + + def to_csapi_dict(self) -> dict: + """Render as an `application/om+json` observation body.""" + return _dump_csapi(self) + + @classmethod + def from_csapi_dict(cls, data: dict) -> "ObservationOMJSONInline": + """Build from an `application/om+json` observation dict.""" + return cls.model_validate(data) class SystemEventOMJSON(BaseModel): @@ -200,3 +468,39 @@ class SystemHistoryProperties(BaseModel): valid_time: list = Field(None) parent_system_link: str = Field(None, serialization_alias='parentSystem@link') procedure_link: str = Field(None, serialization_alias='procedure@link') + + +# Discriminated unions replace the earlier `SerializeAsAny[]` pattern +# on resource models. Pydantic dispatches by the literal value of the +# discriminator field — `obsFormat` / `commandFormat` — so validate and +# dump round-trip without polymorphism quirks. +AnyDatastreamRecordSchema = Annotated[ + Union[ + SWEDatastreamRecordSchema, + SWEBinaryDatastreamRecordSchema, + SWEProtobufDatastreamRecordSchema, + SWEFlatBuffersDatastreamRecordSchema, + OMJSONDatastreamRecordSchema, + ], + Field(discriminator='obs_format'), +] +"""Public alias for `DatastreamResource.record_schema`. Discriminator: `obs_format`.""" + +AnyCommandSchema = Annotated[ + Union[SWEJSONCommandSchema, JSONCommandSchema], + Field(discriminator='command_format'), +] +"""Public alias for `ControlStreamResource.command_schema`. Discriminator: `command_format`.""" + + +# Defense-in-depth: rebuild every container model that forward-references +# `AnyComponent`. See the matching block in swe_components.py for the +# `MockValSer` rationale — same fault recurs here because each schema +# class threads `AnyComponent` through its body. +SWEJSONCommandSchema.model_rebuild(force=True) +JSONCommandSchema.model_rebuild(force=True) +SWEDatastreamRecordSchema.model_rebuild(force=True) +SWEBinaryDatastreamRecordSchema.model_rebuild(force=True) +SWEProtobufDatastreamRecordSchema.model_rebuild(force=True) +SWEFlatBuffersDatastreamRecordSchema.model_rebuild(force=True) +OMJSONDatastreamRecordSchema.model_rebuild(force=True) diff --git a/src/oshconnect/sensorml.py b/src/oshconnect/sensorml.py new file mode 100644 index 0000000..8360459 --- /dev/null +++ b/src/oshconnect/sensorml.py @@ -0,0 +1,127 @@ +# ============================================================================= +# Copyright (c) 2026 Botts Innovative Research Inc. +# Author: Ian Patterson +# ============================================================================= + +"""SensorML 2.0 JSON-encoding structured-field models. + +Three types are modeled here: + +- `Term` — backs `SystemResource.identifiers` and `.classifiers`. Carries + ``{definition, label?, value, codeSpace?, name?}`` per the SensorML + IdentifierTerm / ClassifierTerm shape. +- `Characteristics` and `Capabilities` — back the same-named fields on + `SystemResource`. Each carries ``{definition?, label?, name?, + description?, id?, : [SWE Common component]}`` where + ```` is ``characteristics`` for the former and ``capabilities`` + for the latter. Inner components are typed against ``AnyComponent`` + (the SWE Common discriminated union) and validated to carry a + ``name`` per the SoftNamedProperty binding rule. + +Models are permissive on optional metadata (label, name, description, +id, codeSpace) because OSH and other servers vary in what they include +on the wire. They are strict on the fields the spec marks required: +``Term.definition`` / ``Term.value``, and the inner ``AnyComponent`` +discriminator/name. ``model_rebuild(force=True)`` runs at the bottom so +the recursive forward-ref machinery (each ``AnyComponent`` arm carries +``list["AnyComponent"]``) doesn't leave a `MockValSer` on the +serializer side — same `model_dump_json` regression the schema models +needed.""" +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from .swe_components import AnyComponent, check_named + + +class Term(BaseModel): + """SensorML `IdentifierTerm` / `ClassifierTerm` (SensorML 2.0 §7.2.5). + + Used by ``SystemResource.identifiers`` and ``SystemResource.classifiers``. + The wire shape OSH emits: + + .. code-block:: json + + {"definition": "http://.../SerialNumber", + "label": "Serial Number", + "value": "0123456879"} + """ + model_config = ConfigDict(populate_by_name=True, extra='allow') + + definition: str = Field(..., description="URI naming the term's semantics.") + value: str = Field(..., description="The identifier/classifier value as a string.") + label: str = Field(None, description="Optional display label.") + name: str = Field(None, description="Optional NameToken — the field name in the containing object.") + code_space: str = Field(None, alias='codeSpace', + description="Optional URI naming the codelist `value` belongs to.") + + +class Characteristics(BaseModel): + """SensorML `CharacteristicList` (SensorML 2.0 §7.2.7). + + Used by ``SystemResource.characteristics``. The wire shape carries a + list of inner SWE Common components under the ``characteristics`` + key, where each inner component is bound via SoftNamedProperty and + must therefore carry a ``name``:: + + {"definition": "http://.../OperatingRange", + "label": "Operating Characteristics", + "characteristics": [ + {"type": "QuantityRange", "name": "voltage", …}, + {"type": "QuantityRange", "name": "temperature", …} + ]} + """ + model_config = ConfigDict(populate_by_name=True, extra='allow') + + definition: str = Field(None, + description="URI naming the semantics of the list.") + label: str = Field(None) + description: str = Field(None) + id: str = Field(None) + name: str = Field(None) + # Inner SWE Common components — typed against `AnyComponent` so the + # discriminator on `type` routes to the right concrete subclass. + characteristics: list[AnyComponent] = Field(..., + description="Inner SWE Common components.") + + @model_validator(mode="after") + def _characteristics_require_name(self): + for i, c in enumerate(self.characteristics): + check_named(c, f"Characteristics.characteristics[{i}]") + return self + + +class Capabilities(BaseModel): + """SensorML `CapabilityList` (SensorML 2.0 §7.2.8). + + Used by ``SystemResource.capabilities``. Isomorphic to + `Characteristics` but with the inner-array bucket named + ``capabilities`` instead of ``characteristics``.""" + model_config = ConfigDict(populate_by_name=True, extra='allow') + + definition: str = Field(None, + description="URI naming the semantics of the list.") + label: str = Field(None) + description: str = Field(None) + id: str = Field(None) + name: str = Field(None) + capabilities: list[AnyComponent] = Field(..., + description="Inner SWE Common components.") + + @model_validator(mode="after") + def _capabilities_require_name(self): + for i, c in enumerate(self.capabilities): + check_named(c, f"Capabilities.capabilities[{i}]") + return self + + +# Defense-in-depth: same `MockValSer` rationale as the swe_components.py +# and schema_datamodels.py rebuilds — the recursive forward-ref pattern +# (`list[AnyComponent]` inside Characteristics/Capabilities) needs an +# explicit force-rebuild to fully realize the serializer. +Term.model_rebuild(force=True) +Characteristics.model_rebuild(force=True) +Capabilities.model_rebuild(force=True) + + +__all__ = ["Term", "Characteristics", "Capabilities"] diff --git a/src/oshconnect/streamableresource.py b/src/oshconnect/streamableresource.py index ecd6c56..fa20ead 100644 --- a/src/oshconnect/streamableresource.py +++ b/src/oshconnect/streamableresource.py @@ -1,1204 +1,49 @@ # ============================================================================= -# Copyright (c) 2025 Botts Innovative Research Inc. -# Date: 2025/9/29 +# Copyright (c) 2026 Georobotix Innovative Research +# Date: 2026/5/18 # Author: Ian Patterson -# Contact Email: ian@botts-inc.com +# Contact Email: ian.patterson@georobotix.us # ============================================================================= -from __future__ import annotations - -import asyncio -import base64 -import datetime -import json -import logging -import traceback -import uuid -from abc import ABC -from dataclasses import dataclass, field -from enum import Enum -from multiprocessing import Process -from multiprocessing.queues import Queue -from typing import TypeVar, Generic, Union -from uuid import UUID, uuid4 -from collections import deque - -from pydantic.alias_generators import to_camel - -from .csapi4py.constants import ContentTypes -from .events import EventHandler, DefaultEventTypes -from .events.builder import EventBuilder -from .schema_datamodels import JSONCommandSchema -from .csapi4py.mqtt import MQTTCommClient -from .csapi4py.constants import APIResourceTypes, ObservationFormat -from .csapi4py.default_api_helpers import APIHelper -from .encoding import JSONEncoding -from .resource_datamodels import ControlStreamResource -from .resource_datamodels import DatastreamResource, ObservationResource -from .resource_datamodels import SystemResource -from .schema_datamodels import SWEDatastreamRecordSchema -from .swe_components import DataRecordSchema -from .timemanagement import TimeInstant, TimePeriod, TimeUtils - - -@dataclass(kw_only=True) -class Endpoints: - root: str = "sensorhub" - sos: str = f"{root}/sos" - connected_systems: str = f"{root}/api" - - -class Utilities: - - @staticmethod - def convert_auth_to_base64(username: str, password: str) -> str: - return base64.b64encode(f"{username}:{password}".encode()).decode() - - -class OSHClientSession: - verify_ssl = True - _streamables: dict[str, 'StreamableResource'] = None - - def __init__(self, base_url, *args, verify_ssl=True, **kwargs): - # super().__init__(base_url, *args, **kwargs) - self.verify_ssl = verify_ssl - self._streamables = {} - - def connect_streamables(self): - for streamable in self._streamables.values(): - streamable.start() - - def close_streamables(self): - for streamable in self._streamables.values(): - streamable.stop() - - def register_streamable(self, streamable: StreamableResource): - if self._streamables is None: - self._streamables = {} - self._streamables[streamable.get_streamable_id_str()] = streamable - - -class SessionManager: - _session_tokens = None - sessions: dict[str, OSHClientSession] = None - - def __init__(self, session_tokens: dict[str, str] = None): - self._session_tokens = session_tokens - self.sessions = {} - - def register_session(self, session_id, session: OSHClientSession) -> OSHClientSession: - self.sessions[session_id] = session - return session - - def unregister_session(self, session_id): - session = self.sessions.pop(session_id) - session.close() - - def get_session(self, session_id): - return self.sessions.get(session_id, None) - - def start_session_streams(self, session_id): - session = self.get_session(session_id) - if session is None: - raise ValueError(f"No session found for ID {session_id}") - session.connect_streamables() - - def start_all_streams(self): - for session in self.sessions.values(): - session.connect_streamables() - - -@dataclass(kw_only=True) -class Node: - _id: str - protocol: str - address: str - port: int - server_root: str = 'sensorhub' - endpoints: Endpoints - is_secure: bool - _basic_auth: bytes - _api_helper: APIHelper - _systems: list[System] = field(default_factory=list) - _client_session: OSHClientSession - _mqtt_client: MQTTCommClient - _mqtt_port: int = 1883 - - def __init__(self, protocol: str, address: str, port: int, - username: str = None, password: str = None, server_root: str = 'sensorhub', - api_root: str = 'api', mqtt_topic_root: str = None, - session_manager: SessionManager = None, - **kwargs): - self._id = f'node-{uuid.uuid4()}' - self.protocol = protocol - self.address = address - self.server_root = server_root - self.port = port - self.is_secure = username is not None and password is not None - if self.is_secure: - self.add_basicauth(username, password) - self.endpoints = Endpoints() - self._api_helper = APIHelper( - server_url=self.address, - protocol=self.protocol, - port=self.port, - server_root=self.server_root, - api_root=api_root, - mqtt_topic_root=mqtt_topic_root, - username=username, - password=password) - if self.is_secure: - self._api_helper.user_auth = True - self._systems = [] - if session_manager is not None: - session_task = self.register_with_session_manager(session_manager) - asyncio.gather(session_task) - - if kwargs.get('enable_mqtt'): - if kwargs.get('mqtt_port') is not None: - self._mqtt_port = kwargs.get('mqtt_port') - self._mqtt_client = MQTTCommClient(url=self.address, port=self._mqtt_port, - username=username, password=password, - client_id_suffix=uuid.uuid4().hex, ) - self._mqtt_client.connect() - self._mqtt_client.start() - - def get_id(self): - return self._id - - def get_address(self): - return self.address - - def get_port(self): - return self.port - - def get_api_endpoint(self): - return self._api_helper.get_api_root_url() - - def add_basicauth(self, username: str, password: str): - if not self.is_secure: - self.is_secure = True - self._basic_auth = base64.b64encode( - f"{username}:{password}".encode('utf-8')) - - def get_decoded_auth(self): - return self._basic_auth.decode('utf-8') - - # def get_basicauth(self): - # return BasicAuth(self._api_helper.username, self._api_helper.password) - - def get_mqtt_client(self) -> MQTTCommClient: - return getattr(self, '_mqtt_client', None) - - def discover_systems(self): - result = self._api_helper.retrieve_resource(APIResourceTypes.SYSTEM, - req_headers={}) - if result.ok: - new_systems = [] - system_objs = result.json()['items'] - print(system_objs) - for system_json in system_objs: - print(system_json) - system = SystemResource.model_validate(system_json, by_alias=True) - sys_obj = System(label=system.properties['name'], - name=to_camel(system.properties['name'].replace(" ", "_")), - urn=system.properties['uid'], parent_node=self, resource_id=system.system_id) - - self._systems.append(sys_obj) - new_systems.append(sys_obj) - return new_systems - else: - return None - - def add_new_system(self, system: System): - system.set_parent_node(self) - self._systems.append(system) - - def get_api_helper(self) -> APIHelper: - return self._api_helper - - # System Management - - def add_system(self, system: System, insert_resource: bool = False): - """ - Add a system to the target node. - :param system: System object - :param insert_resource: Whether to insert the system into the target node's server, default is False - :return: - """ - if insert_resource: - system.insert_self() - self.add_new_system(system) - self._systems.append(system) - return system - - def systems(self) -> list[System]: - return self._systems - - def register_with_session_manager(self, session_manager: SessionManager): - """ - Registers this node with the provided session manager, creating a new client session. - :param session_manager: SessionManager instance - """ - self._client_session = session_manager.register_session(self._id, OSHClientSession( - base_url=self._api_helper.get_base_url())) - - def register_streamable(self, streamable: StreamableResource): - if self._client_session is None: - raise ValueError("Node is not registered with a SessionManager.") - self._client_session.register_streamable(streamable) - - def get_session(self) -> OSHClientSession: - return self._client_session - - def serialize(self) -> dict: - data = { - "_id": self._id, - "protocol": self.protocol, - "address": self.address, - "port": self.port, - "server_root": self.server_root, - "api_root": getattr(self._api_helper, "api_root", "api"), - "mqtt_topic_root": getattr(self._api_helper, "mqtt_topic_root", None), - "is_secure": self.is_secure, - "username": getattr(self._api_helper, "username", None), - "password": getattr(self._api_helper, "password", None), - "_systems": [system.serialize() for system in self._systems] if self._systems is not None else None, - } - data["name"] = getattr(self, "name", None) - data["label"] = getattr(self, "label", None) - data["urn"] = getattr(self, "urn", None) - data["description"] = getattr(self, "description", None) - datastreams = getattr(self, "datastreams", None) - if datastreams is not None: - data["datastreams"] = [ds.serialize() for ds in datastreams] - else: - data["datastreams"] = None - control_channels = getattr(self, "control_channels", None) - if control_channels is not None: - data["control_channels"] = [cc.serialize() for cc in control_channels] - else: - data["control_channels"] = None - underlying = getattr(self, "_underlying_resource", None) - if underlying is not None: - dump = getattr(underlying, 'model_dump', None) - if callable(dump): - data["underlying_resource"] = underlying.model_dump(by_alias=True, exclude_none=True, mode='json') - elif hasattr(underlying, 'to_dict'): - data["underlying_resource"] = underlying.to_dict() - else: - data["underlying_resource"] = str(underlying) - else: - data["underlying_resource"] = None - # Remove any 'resource' key if present - data.pop("resource", None) - return data - - @classmethod - def deserialize(cls, data: dict, session_manager: 'SessionManager' = None) -> 'Node': - node = cls( - protocol=data["protocol"], - address=data["address"], - port=data["port"], - username=data.get("username"), - password=data.get("password"), - server_root=data.get("server_root", "sensorhub"), - api_root=data.get("api_root", "api"), - mqtt_topic_root=data.get("mqtt_topic_root"), - ) - node._id = data["_id"] - node.is_secure = data.get("is_secure", False) - # Register with the session manager before deserializing child resources, - # because StreamableResource.__init__ calls node.register_streamable(). - if session_manager is not None: - node.register_with_session_manager(session_manager) - node._systems = [System.deserialize(sys, node) for sys in data.get("_systems", [])] if data.get( - "_systems") is not None else [] - return node - - -class Status(Enum): - INITIALIZING = "initializing" - INITIALIZED = "initialized" - STARTING = "starting" - STARTED = "started" - STOPPING = "stopping" - STOPPED = "stopped" - - -class StreamableModes(Enum): - PUSH = "push" - PULL = "pull" - BIDIRECTIONAL = "bidirectional" - - -T = TypeVar('T', SystemResource, DatastreamResource, ControlStreamResource) - - -class StreamableResource(Generic[T], ABC): - _id: UUID - _resource_id: str - # _canonical_link: str - _topic: str - _status: str = Status.STOPPED.value - ws_url: str - _message_handler = None - _parent_node: Node - _underlying_resource: T - _process: Process - _msg_reader_queue: asyncio.Queue[Union[str, bytes, float, int]] - _msg_writer_queue: asyncio.Queue[Union[str, bytes, float, int]] - _inbound_deque: deque - _outbound_deque: deque - _mqtt_client: MQTTCommClient - _parent_resource_id: str - _connection_mode: StreamableModes = StreamableModes.PUSH.value - - def __init__(self, node: Node, connection_mode: StreamableModes = StreamableModes.PUSH.value): - self._id = uuid4() - self._parent_node = node - self._parent_node.register_streamable(self) - self._mqtt_client = self._parent_node.get_mqtt_client() - self._connection_mode = connection_mode - self._inbound_deque = deque() - self._outbound_deque = deque() - self._parent_resource_id = None - - def get_streamable_id(self) -> UUID: - return self._id - - def get_streamable_id_str(self) -> str: - return self._id.hex - - def initialize(self): - resource_type = None - if isinstance(self._underlying_resource, SystemResource): - resource_type = APIResourceTypes.SYSTEM - elif isinstance(self._underlying_resource, DatastreamResource): - resource_type = APIResourceTypes.DATASTREAM - elif isinstance(self._underlying_resource, ControlStreamResource): - resource_type = APIResourceTypes.CONTROL_CHANNEL - if resource_type is None: - raise ValueError( - "Underlying resource must be set to either SystemResource or DatastreamResource before initialization.") - # This needs to be implemented separately for each subclass - res_id = getattr(self._underlying_resource, "ds_id", None) or getattr(self._underlying_resource, "cs_id", None) - self.ws_url = self._parent_node.get_api_helper().construct_url(resource_type=resource_type, - subresource_type=APIResourceTypes.OBSERVATION, - resource_id=res_id, - subresource_id=None) - self._msg_reader_queue = asyncio.Queue() - self._msg_writer_queue = asyncio.Queue() - self.init_mqtt() - self._status = Status.INITIALIZED.value - - def start(self): - if self._status != Status.INITIALIZED.value: - logging.warning(f"Streamable resource {self._id} not initialized. Call initialize() first.") - return - self._status = Status.STARTING.value - self._status = Status.STARTED.value - - async def stream(self): - session = self._parent_node.get_session() - - try: - async with session.ws_connect(self.ws_url, auth=self._parent_node.get_basicauth()) as ws: - logging.info(f"Streamable resource {self._id} started.") - read_task = asyncio.create_task(self._read_from_ws(ws)) - write_task = asyncio.create_task(self._write_to_ws(ws)) - await asyncio.gather(read_task, write_task) - except Exception as e: - logging.error(f"Error in streamable resource {self._id}: {e}") - logging.error(traceback.format_exc()) - - def init_mqtt(self): - if self._mqtt_client is None: - logging.warning(f"No MQTT client configured for streamable resource {self._id}.") - return - - self._mqtt_client.set_on_subscribe(self._default_on_subscribe) - - # self.get_mqtt_topic() - - def _default_on_subscribe(self, client, userdata, mid, granted_qos, properties): - logging.debug("OSH Subscribed: mid=%s granted_qos=%s", mid, granted_qos) - - def get_mqtt_topic(self, subresource: APIResourceTypes | None = None, data_topic: bool = True): - """ - Retrieves the MQTT topic for this streamable resource based on its underlying resource type. By default, - returns a Resource Data Topic (`:data` suffix per CS API Part 3). - :param subresource: Optional subresource type to get the topic for, defaults to None - :param data_topic: If True (default), produces a Resource Data Topic with ':data' suffix. Set False for - Resource Event Topics. - """ - resource_type = None - parent_res_type = None - parent_id = None - - if isinstance(self._underlying_resource, ControlStreamResource): - parent_res_type = APIResourceTypes.CONTROL_CHANNEL - parent_id = self._resource_id - - match subresource: - case APIResourceTypes.COMMAND: - resource_type = APIResourceTypes.COMMAND - case APIResourceTypes.STATUS: - resource_type = APIResourceTypes.STATUS - - elif isinstance(self._underlying_resource, DatastreamResource): - parent_res_type = APIResourceTypes.DATASTREAM - resource_type = APIResourceTypes.OBSERVATION - parent_id = self._resource_id - - elif isinstance(self._underlying_resource, SystemResource): - match subresource: - case APIResourceTypes.DATASTREAM: - resource_type = APIResourceTypes.DATASTREAM - parent_res_type = APIResourceTypes.SYSTEM - parent_id = self._resource_id - case APIResourceTypes.CONTROL_CHANNEL: - resource_type = APIResourceTypes.CONTROL_CHANNEL - parent_res_type = APIResourceTypes.SYSTEM - parent_id = self._resource_id - case None: - resource_type = APIResourceTypes.SYSTEM - parent_res_type = None - parent_id = None - case _: - raise ValueError(f"Unsupported subresource type {subresource} for SystemResource.") - - topic = self._parent_node.get_api_helper().get_mqtt_topic(subresource_type=resource_type, - resource_id=parent_id, - resource_type=parent_res_type, - data_topic=data_topic) - return topic - - def get_event_topic(self) -> str: - """ - Returns the Resource Event Topic for this streamable resource per CS API Part 3. Event topics point to the - resource itself (no ':data' suffix) and are used to receive CloudEvents lifecycle notifications - (create/update/delete) published by the server. - - For Datastream/ControlStream, includes the parent system path when a parent resource ID is available. - """ - mqtt_root = self._parent_node.get_api_helper().get_mqtt_root() - - if isinstance(self._underlying_resource, DatastreamResource): - if self._parent_resource_id: - return f'{mqtt_root}/systems/{self._parent_resource_id}/datastreams/{self._resource_id}' - return f'{mqtt_root}/datastreams/{self._resource_id}' - - elif isinstance(self._underlying_resource, ControlStreamResource): - if self._parent_resource_id: - return f'{mqtt_root}/systems/{self._parent_resource_id}/controlstreams/{self._resource_id}' - return f'{mqtt_root}/controlstreams/{self._resource_id}' - - elif isinstance(self._underlying_resource, SystemResource): - return f'{mqtt_root}/systems/{self._resource_id}' - - raise ValueError(f"Cannot determine event topic for resource type {type(self._underlying_resource)}") - - def subscribe_events(self, callback=None, qos: int = 0) -> str: - """ - Subscribes to the Resource Event Topic for this streamable resource. Event messages are CloudEvents v1.0 - JSON payloads published by the server when the resource is created, updated, or deleted. - - :param callback: Optional message callback. If None, uses the default handler (appends to inbound deque). - :param qos: MQTT Quality of Service level, default 0. - :return: The event topic string that was subscribed to. - """ - if self._mqtt_client is None: - logging.warning(f"No MQTT client configured for streamable resource {self._id}.") - return "" - event_topic = self.get_event_topic() - cb = callback if callback is not None else self._mqtt_sub_callback - self._mqtt_client.subscribe(event_topic, qos=qos, msg_callback=cb) - return event_topic - - async def _read_from_ws(self, ws): - async for msg in ws: - self._message_handler(ws, msg) - - async def _write_to_ws(self, ws): - while self._status is Status.STARTED.value: - try: - msg = self._msg_writer_queue.get_nowait() - await ws.send_bytes(msg) - except asyncio.QueueEmpty: - await asyncio.sleep(0.05) - - def stop(self): - # It would be nicer to join() here once we have cleaner shutdown logic in place to avoid corrupting processes - # that are writing to streams or that need to manage authentication state - self._status = "stopping" - self._process.terminate() - self._status = "stopped" - - def set_parent_node(self, node: Node): - self._parent_node = node - - def get_parent_node(self) -> Node: - return self._parent_node - - def set_parent_resource_id(self, res_id: str): - self._parent_resource_id = res_id - - def get_parent_resource_id(self) -> str: - return self._parent_resource_id - - def set_connection_mode(self, connection_mode: StreamableModes): - self._connection_mode = connection_mode - - def poll(self): - pass - - def fetch(self, time_period: TimePeriod): - pass - - def get_msg_reader_queue(self) -> Queue: - """ - Returns the message queue for this streamable resource. In cases where a custom message handler is used this is - not guaranteed to return anything or provided a queue with data. - :return: Queue object - """ - return self._msg_reader_queue - - def get_msg_writer_queue(self) -> Queue: - """ - Returns the message queue for writing messages to this streamable resource. - :return: Queue object - """ - return self._msg_writer_queue - - def get_underlying_resource(self) -> T: - return self._underlying_resource - - def get_internal_id(self) -> UUID: - return self._id - - def insert_data(self, data: dict): - """ Naively inserts data into the message writer queue to be sent over the WebSocket connection. - No Checks are performed to ensure the data is valid for the underlying resource. - :param data: Data to be sent, typically bytes or str - """ - print(f"Inserting data into message writer queue: {data}") - data_bytes = json.dumps(data).encode("utf-8") if isinstance(data, dict) else data - self._msg_writer_queue.put_nowait(data_bytes) - - def subscribe_mqtt(self, topic: str, qos: int = 0): - if self._mqtt_client is None: - logging.warning(f"No MQTT client configured for streamable resource {self._id}.") - return - self._mqtt_client.subscribe(topic, qos=qos, msg_callback=self._mqtt_sub_callback) - - def _publish_mqtt(self, topic, payload): - if self._mqtt_client is None: - logging.warning("No MQTT client configured for streamable resource %s.", self._id) - return - logging.debug("Publishing to MQTT topic %s", topic) - self._mqtt_client.publish(topic, payload, qos=0) - - async def _write_to_mqtt(self): - while self._status == Status.STARTED.value: - try: - msg = self._outbound_deque.popleft() - logging.debug("Publishing outbound message from %s", self._id) - self._publish_mqtt(self._topic, msg) - except IndexError: - await asyncio.sleep(0.05) - except Exception as e: - logging.error("Error in Write To MQTT %s: %s\n%s", self._id, e, traceback.format_exc()) - if self._status == Status.STOPPED.value: - logging.debug("MQTT write task stopping: resource %s stopped", self._id) - - def publish(self, payload, topic: str = None): - """ - Publishes data to the MQTT topic associated with this streamable resource. - :param payload: Data to be published, subclass should determine specifically allowed types - :param topic: Specific implementation determines the topic from the provided string, if None the default topic is used - """ - self._publish_mqtt(self._topic, payload) - - def subscribe(self, topic=None, callback=None, qos=0): - """ - Subscribes to the MQTT topic associated with this streamable resource. - :param topic: Specific implementation determines the topic from the provided string, if None the default topic is used - :param callback: Optional callback function to handle incoming messages, if None the default handler is used - :param qos: Quality of Service level for the subscription, default is 0 - """ - t = None - - if topic is None: - t = self._topic - else: - raise ValueError("Invalid topic provided, must be None to use default topic.") - - if callback is None: - self._mqtt_client.subscribe(t, qos=qos, msg_callback=self._mqtt_sub_callback) - else: - self._mqtt_client.subscribe(t, qos=qos, msg_callback=callback) - - def _mqtt_sub_callback(self, client, userdata, msg): - logging.debug("Received MQTT message on topic %s (%s bytes)", msg.topic, len(msg.payload)) - # Appends to right of deque - self._inbound_deque.append(msg.payload) - self._emit_inbound_event(msg) - - def _emit_inbound_event(self, msg): - """Hook for subclasses to publish EventHandler events on incoming MQTT messages.""" - pass - - def get_inbound_deque(self): - return self._inbound_deque - - def get_outbound_deque(self): - return self._outbound_deque - - def serialize(self) -> dict: - """Serializes common attributes of StreamableResource, safely handling missing/None attributes.""" - topic = getattr(self, "_topic", None) - status = getattr(self, "_status", None) - parent_resource_id = getattr(self, "_parent_resource_id", None) - connection_mode = getattr(self, "_connection_mode", None) - resource_id = getattr(self, "_resource_id", None) - if isinstance(connection_mode, Enum): - connection_mode = connection_mode.value - - return { - "id": str(getattr(self, "_id", None)), - "resource_id": resource_id, - # "canonical_link": getattr(self, "_canonical_link", None), - "topic": topic, - "status": status, - "parent_resource_id": parent_resource_id, - "connection_mode": connection_mode, - } - - @classmethod - def deserialize(cls, data: dict, node: 'Node') -> 'StreamableResource': - """Deserializes common attributes. Subclasses should override and call super().""" - obj = cls(node=node) - obj._id = uuid.UUID(data["id"]) - obj._resource_id = data.get("resource_id") - # obj._canonical_link = data.get("canonical_link") - obj._topic = data.get("topic") - obj._status = data.get("status") - obj._parent_resource_id = data.get("parent_resource_id") - obj._connection_mode = StreamableModes(data.get("connection_mode", StreamableModes.PUSH.value)), - return obj - - -class System(StreamableResource[SystemResource]): - name: str - label: str - datastreams: list[Datastream] - control_channels: list[ControlStream] - description: str - urn: str - _parent_node: Node - - def __init__(self, name: str, label: str, urn: str, parent_node: Node, **kwargs): - """ - :param name: The machine-accessible name of the system - :param label: The human-readable label of the system - :param urn: The URN of the system, typically formed as such: 'urn:general_identifier:specific_identifier:more_specific_identifier' - :param kwargs: - - 'description': A description of the system - """ - super().__init__(node=parent_node) - self.name = name - self.label = label - self.datastreams = [] - self.control_channels = [] - self.urn = urn - if kwargs.get('resource_id'): - self._resource_id = kwargs['resource_id'] - if kwargs.get('description'): - self.description = kwargs['description'] - - self._underlying_resource = self.to_system_resource() - - def discover_datastreams(self) -> list[Datastream]: - res = self._parent_node.get_api_helper().get_resource(APIResourceTypes.SYSTEM, self._resource_id, - APIResourceTypes.DATASTREAM) - datastream_json = res.json()['items'] - datastreams = [] - - for ds in datastream_json: - datastream_objs = DatastreamResource.model_validate(ds, by_alias=True) - new_ds = Datastream(self._parent_node, datastream_objs) - datastreams.append(new_ds) - - if not [ds.get_underlying_resource() != datastream_objs for ds in self.datastreams]: - self.datastreams.append(new_ds) - - return datastreams - - def discover_controlstreams(self) -> list[ControlStream]: - res = self._parent_node.get_api_helper().get_resource(APIResourceTypes.SYSTEM, self._resource_id, - APIResourceTypes.CONTROL_CHANNEL) - controlstream_json = res.json()['items'] - controlstreams = [] - - for cs_json in controlstream_json: - controlstream_objs = ControlStreamResource.model_validate(cs_json) - new_cs = ControlStream(self._parent_node, controlstream_objs) - controlstreams.append(new_cs) - - if not [cs.get_underlying_resource() != controlstream_objs for cs in self.control_channels]: - self.control_channels.append(new_cs) - - return controlstreams - - @staticmethod - def from_system_resource(system_resource: SystemResource, parent_node: Node) -> System: - other_props = system_resource.model_dump() - print(f'Props of SystemResource: {other_props}') - - # case 1: has properties a la geojson - if 'properties' in other_props: - new_system = System(name=other_props['properties']['name'], - label=other_props['properties']['name'], - urn=other_props['properties']['uid'], - resource_id=system_resource.system_id, parent_node=parent_node) - else: - new_system = System(name=system_resource.name, - label=system_resource.label, urn=system_resource.urn, - resource_id=system_resource.system_id, parent_node=parent_node) - - new_system.set_system_resource(system_resource) - return new_system - - def to_system_resource(self) -> SystemResource: - resource = SystemResource(uid=self.urn, label=self.name, feature_type='PhysicalSystem') - - if len(self.datastreams) > 0: - resource.outputs = [ds.get_underlying_resource() for ds in self.datastreams] - - # if len(self.control_channels) > 0: - # resource.inputs = [cc.to_resource() for cc in self.control_channels] - return resource - - def set_system_resource(self, sys_resource: SystemResource): - self._underlying_resource = sys_resource - - def get_system_resource(self) -> SystemResource: - return self._underlying_resource - - def add_insert_datastream(self, datarecord_schema: DataRecordSchema): - """ - Adds a datastream to the system while also inserting it into the system's parent node via HTTP POST. - :param datarecord_schema: DataRecordSchema to be used to define the datastream. Must carry a `name` - matching NameToken (^[A-Za-z][A-Za-z0-9_\\-]*$); SWE Common 3 wraps DataStream.elementType in - SoftNamedProperty, so the root component requires a name. - :return: - """ - print(f'Adding datastream: {datarecord_schema.model_dump_json(exclude_none=True, by_alias=True)}') - # Make the request to add the datastream - # if successful, add the datastream to the system - datastream_schema = SWEDatastreamRecordSchema(record_schema=datarecord_schema, - obs_format='application/swe+json', - encoding=JSONEncoding()) - datastream_resource = DatastreamResource(ds_id="default", name=datarecord_schema.label, - output_name=datarecord_schema.label, - record_schema=datastream_schema, - valid_time=TimePeriod(start=TimeInstant.now_as_time_instant(), - end=TimeInstant(utc_time=TimeUtils.to_utc_time( - "2026-12-31T00:00:00Z")))) - - api = self._parent_node.get_api_helper() - print( - f'Attempting to create datastream: {datastream_resource.model_dump(by_alias=True, exclude_none=True)}') - res = api.create_resource(APIResourceTypes.DATASTREAM, - datastream_resource.model_dump_json(by_alias=True, exclude_none=True), - req_headers={ - 'Content-Type': ContentTypes.JSON.value - }, parent_res_id=self._resource_id) - - if res.ok: - datastream_id = res.headers['Location'].split('/')[-1] - print(f'Resource Location: {datastream_id}') - datastream_resource.ds_id = datastream_id - else: - raise Exception(f'Failed to create datastream: {datastream_resource.name}') - - new_ds = Datastream(self._parent_node, datastream_resource) - new_ds.set_parent_resource_id(self._underlying_resource.system_id) - self.datastreams.append(new_ds) - return new_ds - - def add_and_insert_control_stream(self, control_stream_record_schema: DataRecordSchema, input_name: str = None, - valid_time: TimePeriod = None) -> ControlStream: - """ - Accepts a DataRecordSchema and creates a JSON encoded schema structure ControlStreamResource, which is inserted - into the parent system via the host node. - :param control_stream_record_schema: DataRecordSchema to be used for the control stream. Must carry a `name` - matching NameToken (^[A-Za-z][A-Za-z0-9_\\-]*$); JSONCommandSchema.parametersSchema is wrapped in - SoftNamedProperty so the root component requires a name. - :param input_name: Name of the input, if None the label of the schema is converted to lower and stripped of whitespace - :return: ControlStream object added to the system - """ - input_name_checked = input_name if input_name is not None else control_stream_record_schema.label.lower().replace( - ' ', '') - - now = datetime.datetime.now() - future_time = now.replace(year=now.year + 1) - future_str = future_time.strftime("%Y-%m-%dT%H:%M:%SZ") - - valid_time_checked = valid_time if valid_time else TimePeriod(start=TimeInstant.now_as_time_instant(), - end=TimeInstant( - utc_time=TimeUtils.to_utc_time(future_str))) - - command_schema = JSONCommandSchema(command_format=ObservationFormat.SWE_JSON.value, - params_schema=control_stream_record_schema) - control_stream_resource = ControlStreamResource(name=control_stream_record_schema.label, - input_name=input_name_checked, - command_schema=command_schema, - validTime=valid_time_checked) - api = self._parent_node.get_api_helper() - res = api.create_resource(APIResourceTypes.CONTROL_CHANNEL, - control_stream_resource.model_dump_json(by_alias=True, exclude_none=True), - req_headers={ - 'Content-Type': 'application/json' - }, parent_res_id=self._resource_id) - - if res.ok: - control_channel_id = res.headers['Location'].split('/')[-1] - print(f'Control Stream Resource Location: {control_channel_id}') - control_stream_resource.cs_id = control_channel_id - else: - raise Exception(f'Failed to create control stream: {control_stream_resource.name}') - - new_cs = ControlStream(node=self._parent_node, controlstream_resource=control_stream_resource) - new_cs.set_parent_resource_id(self._underlying_resource.system_id) - self.control_channels.append(new_cs) - return new_cs - - def insert_self(self): - res = self._parent_node.get_api_helper().create_resource( - APIResourceTypes.SYSTEM, self.to_system_resource().model_dump_json(by_alias=True, exclude_none=True), - req_headers={ - 'Content-Type': 'application/sml+json' - }) - - if res.ok: - location = res.headers['Location'] - sys_id = location.split('/')[-1] - self._resource_id = sys_id - print(f'Created system: {self._resource_id}') - - def retrieve_resource(self): - if self._resource_id is None: - return None - res = self._parent_node.get_api_helper().retrieve_resource(res_type=APIResourceTypes.SYSTEM, - res_id=self._resource_id) - if res.ok: - system_json = res.json() - print(system_json) - system_resource = SystemResource.model_validate(system_json) - print(f'System Resource: {system_resource}') - self._underlying_resource = system_resource - return None - - def serialize(self) -> dict: - data = super().serialize() - data["name"] = getattr(self, "name", None) - data["label"] = getattr(self, "label", None) - data["urn"] = getattr(self, "urn", None) - data["description"] = getattr(self, "description", None) - datastreams = getattr(self, "datastreams", None) - if datastreams is not None: - data["datastreams"] = [ds.serialize() for ds in datastreams] - else: - data["datastreams"] = None - control_channels = getattr(self, "control_channels", None) - if control_channels is not None: - data["control_channels"] = [cc.serialize() for cc in control_channels] - else: - data["control_channels"] = None - underlying = getattr(self, "_underlying_resource", None) - if underlying is not None: - dump = getattr(underlying, 'model_dump', None) - if callable(dump): - data["underlying_resource"] = underlying.model_dump(by_alias=True, exclude_none=True, mode='json') - elif hasattr(underlying, 'to_dict'): - data["underlying_resource"] = underlying.to_dict() - else: - data["underlying_resource"] = str(underlying) - else: - data["underlying_resource"] = None - # Remove any 'resource' key if present - data.pop("resource", None) - return data - - @classmethod - def deserialize(cls, data: dict, node: 'Node') -> 'System': - obj = cls( - name=data["name"], - label=data["label"], - urn=data["urn"], - parent_node=node, - description=data.get("description"), - resource_id=data.get("resource_id") - ) - obj._id = uuid.UUID(data["id"]) - obj.datastreams = [Datastream.deserialize(ds, node) for ds in data.get("datastreams", [])] - obj.control_channels = [ControlStream.deserialize(cc, node) for cc in data.get("control_channels", [])] - underlying = data.get("underlying_resource") - obj._underlying_resource = SystemResource.model_validate(underlying) if underlying else None - return obj - - -class Datastream(StreamableResource[DatastreamResource]): - should_poll: bool - - def __init__(self, parent_node: Node = None, datastream_resource: DatastreamResource = None): - super().__init__(node=parent_node) - self._underlying_resource = datastream_resource - self._resource_id = datastream_resource.ds_id - - def get_id(self): - return self._underlying_resource.ds_id - - @staticmethod - def from_resource(ds_resource: DatastreamResource, parent_node: Node): - new_ds = Datastream(parent_node=parent_node, datastream_resource=ds_resource) - return new_ds - - def set_resource(self, resource: DatastreamResource): - self._underlying_resource = resource - - def get_resource(self) -> DatastreamResource: - return self._underlying_resource - - def create_observation(self, obs_data: dict): - obs = ObservationResource(result=obs_data, result_time=TimeInstant.now_as_time_instant()) - # Validate against the schema - if self._underlying_resource.record_schema is not None: - obs.validate_against_schema(self._underlying_resource.record_schema) - return obs - - def insert_observation_dict(self, obs_data: dict): - res = self._parent_node.get_api_helper().create_resource(APIResourceTypes.OBSERVATION, obs_data, - parent_res_id=self._resource_id, - req_headers={'Content-Type': 'application/json'}) - if res.ok: - obs_id = res.headers['Location'].split('/')[-1] - print(f'Inserted observation: {obs_id}') - return id - else: - raise Exception(f'Failed to insert observation: {res.text}') - - def start(self): - super().start() - if self._mqtt_client is not None: - if self._connection_mode is StreamableModes.PULL or self._connection_mode is StreamableModes.BIDIRECTIONAL: - self._mqtt_client.subscribe(self._topic, msg_callback=self._mqtt_sub_callback) - else: - try: - loop = asyncio.get_running_loop() - loop.create_task(self._write_to_mqtt()) - except RuntimeError: - logging.warning("No running event loop — MQTT write task for %s not started. " - "Call start() from within an async context.", self._id) - except Exception as e: - logging.error("Error starting MQTT write task for %s: %s\n%s", - self._id, e, traceback.format_exc()) - - def init_mqtt(self): - super().init_mqtt() - self._topic = self.get_mqtt_topic(subresource=APIResourceTypes.OBSERVATION, data_topic=True) - - def _emit_inbound_event(self, msg): - evt = (EventBuilder().with_type(DefaultEventTypes.NEW_OBSERVATION) - .with_topic(msg.topic) - .with_data(msg.payload) - .with_producer(self) - .build()) - EventHandler().publish(evt) - - def _queue_push(self, msg): - print(f'Pushing message to reader queue: {msg}') - self._msg_writer_queue.put_nowait(msg) - print(f'Queue size is now: {self._msg_writer_queue.qsize()}') - - def _queue_pop(self): - return self._msg_reader_queue.get_nowait() - - def insert(self, data: dict): - # self._queue_push(data) - encoded = json.dumps(data).encode('utf-8') - self._publish_mqtt(self._topic, encoded) - - def serialize(self) -> dict: - data = super().serialize() - data["should_poll"] = getattr(self, "should_poll", None) - underlying = getattr(self, "_underlying_resource", None) - if underlying is not None: - dump = getattr(underlying, 'model_dump', None) - if callable(dump): - data["underlying_resource"] = underlying.model_dump(by_alias=True, exclude_none=True, mode='json') - elif hasattr(underlying, 'to_dict'): - data["underlying_resource"] = underlying.to_dict() - else: - data["underlying_resource"] = str(underlying) - else: - data["underlying_resource"] = None - - return data - - @classmethod - def deserialize(cls, data: dict, node: 'Node') -> 'Datastream': - ds_resource = DatastreamResource.model_validate(data["underlying_resource"]) if data.get("underlying_resource") else None - obj = cls(parent_node=node, datastream_resource=ds_resource) - obj._id = uuid.UUID(data["id"]) - obj.should_poll = data.get("should_poll", False) - return obj - - def subscribe(self, topic=None, callback=None, qos=0): - t = None - - if topic is None or topic == APIResourceTypes.OBSERVATION.value: - t = self._topic - # elif topic == APIResourceTypes.STATUS.value: - # t = self._status_topic - else: - raise ValueError(f"Invalid topic provided {topic}, must be None or 'observation'.") - - if callback is None: - self._mqtt_client.subscribe(t, qos=qos, msg_callback=self._mqtt_sub_callback) - else: - self._mqtt_client.subscribe(t, qos=qos, msg_callback=callback) - - -class ControlStream(StreamableResource[ControlStreamResource]): - _status_topic: str - _inbound_status_deque: deque - _outbound_status_deque: deque - - def __init__(self, node: Node = None, controlstream_resource: ControlStreamResource = None): - super().__init__(node=node) - self._underlying_resource = controlstream_resource - self._inbound_status_deque = deque() - self._outbound_status_deque = deque() - self._resource_id = controlstream_resource.cs_id - # Always make sure this is set after the resource ids are set - self._status_topic = self.get_mqtt_status_topic() - - def add_underlying_resource(self, resource: ControlStreamResource): - self._underlying_resource = resource - - def init_mqtt(self): - super().init_mqtt() - self._topic = self.get_mqtt_topic(subresource=APIResourceTypes.COMMAND, data_topic=True) - - def get_mqtt_status_topic(self): - return self.get_mqtt_topic(subresource=APIResourceTypes.STATUS, data_topic=True) - - def _emit_inbound_event(self, msg): - evt_type = (DefaultEventTypes.NEW_COMMAND - if msg.topic == self._topic - else DefaultEventTypes.NEW_COMMAND_STATUS) - evt = (EventBuilder().with_type(evt_type) - .with_topic(msg.topic) - .with_data(msg.payload) - .with_producer(self) - .build()) - EventHandler().publish(evt) - - def start(self): - super().start() - if self._mqtt_client is not None: - if self._connection_mode is StreamableModes.PULL or self._connection_mode is StreamableModes.BIDIRECTIONAL: - # Subs to command topic by default - self._mqtt_client.subscribe(self._topic, msg_callback=self._mqtt_sub_callback) - else: - try: - loop = asyncio.get_running_loop() - loop.create_task(self._write_to_mqtt()) - except RuntimeError: - logging.warning("No running event loop — MQTT write task for %s not started. " - "Call start() from within an async context.", self._id) - except Exception as e: - logging.error("Error starting MQTT write task for %s: %s\n%s", - self._id, e, traceback.format_exc()) - - def get_inbound_deque(self): - return self._inbound_deque - - def get_outbound_deque(self): - return self._outbound_deque - - def get_status_deque_inbound(self): - return self._inbound_status_deque - - def get_status_deque_outbound(self): - return self._outbound_status_deque - - def publish_command(self, payload): - self.publish(payload, topic=APIResourceTypes.COMMAND.value) - - def publish_status(self, payload): - self.publish(payload, topic=APIResourceTypes.STATUS.value) - - def publish(self, payload, topic: str = 'command'): - """ - Publishes data to the MQTT topic associated with this control stream resource. - :param payload: Data to be published, subclass should determine specifically allowed types - :param topic: Specific implementation determines the topic from the provided string - """ - - if topic == APIResourceTypes.COMMAND.value: - self._publish_mqtt(self._topic, payload) - elif topic == APIResourceTypes.STATUS.value: - self._publish_mqtt(self._status_topic, payload) - else: - raise ValueError(f"Unsupported topic type {topic} for ControlStream publish().") - - def subscribe(self, topic=None, callback=None, qos=0): - """ - Subscribes to the MQTT topic associated with this control stream resource. - :param topic: Specific implementation determines the topic from the provided string - :param callback: Optional callback function to handle incoming messages, if None the default handler is used - :param qos: Quality of Service level for the subscription, default is 0 - """ - - t = None - - if topic is None or topic == APIResourceTypes.COMMAND.value: - t = self._topic - elif topic == APIResourceTypes.STATUS.value: - t = self._status_topic - else: - raise ValueError(f"Invalid topic provided {topic}, must be None or one of 'command' or 'status'.") - - if callback is None: - self._mqtt_client.subscribe(t, qos=qos, msg_callback=self._mqtt_sub_callback) - else: - self._mqtt_client.subscribe(t, qos=qos, msg_callback=callback) - - def serialize(self) -> dict: - data = super().serialize() - data["status_topic"] = getattr(self, "_status_topic", None) - underlying = getattr(self, "_underlying_resource", None) - if underlying is not None: - dump = getattr(underlying, 'model_dump', None) - if callable(dump): - data["underlying_resource"] = underlying.model_dump(by_alias=True, exclude_none=True, mode='json') - elif hasattr(underlying, 'to_dict'): - data["underlying_resource"] = underlying.to_dict() - else: - data["underlying_resource"] = str(underlying) - else: - data["underlying_resource"] = None - - return data - - @classmethod - def deserialize(cls, data: dict, node: 'Node') -> 'ControlStream': - cs_resource = ControlStreamResource.model_validate(data["underlying_resource"]) if data.get("underlying_resource") else None - obj = cls(node=node, controlstream_resource=cs_resource) - obj._id = uuid.UUID(data["id"]) - obj._status_topic = data.get("status_topic") - return obj +"""Backward-compatible re-export shim. + +The classes that used to live in this module have moved into focused +sibling modules: + +- `Node`, `SessionManager`, `OSHClientSession`, `Endpoints`, `Utilities` + → `oshconnect.node` +- `StreamableResource`, `Status`, `StreamableModes`, `SchemaFetchWarning` + → `oshconnect.resources.base` +- `System` → `oshconnect.resources.system` +- `Datastream` → `oshconnect.resources.datastream` +- `ControlStream` → `oshconnect.resources.controlstream` + +Existing ``from oshconnect.streamableresource import X`` paths continue +to resolve through this shim. Prefer importing from `oshconnect` directly +or from the new sibling modules in new code. +""" +from .node import Endpoints, Node, OSHClientSession, SessionManager, Utilities +from .resources.base import ( + SchemaFetchWarning, + Status, + StreamableModes, + StreamableResource, +) +from .resources.controlstream import ControlStream +from .resources.datastream import Datastream +from .resources.system import System + +__all__ = [ + "ControlStream", + "Datastream", + "Endpoints", + "Node", + "OSHClientSession", + "SchemaFetchWarning", + "SessionManager", + "Status", + "StreamableModes", + "StreamableResource", + "System", + "Utilities", +] diff --git a/src/oshconnect/swe_binary.py b/src/oshconnect/swe_binary.py new file mode 100644 index 0000000..5cb8a39 --- /dev/null +++ b/src/oshconnect/swe_binary.py @@ -0,0 +1,478 @@ +# ============================================================================= +# Copyright (c) 2026 Georobotix Innovative Research +# Date: 2026/5/19 +# Author: Ian Patterson +# Contact Email: ian.patterson@georobotix.us +# ============================================================================= + +"""Runtime codec for the SWE Common BinaryEncoding wire format. + +Two complementary entry points: + +* **Low-level helpers** (`encode_swe_binary_blob`, `encode_swe_binary_record`, + `decode_swe_binary_blob`, `decode_swe_binary_record`) — small, dependency-free + functions for the two shapes that dominate in practice: + + 1. **Variable-size block**: ``[ts: 8B BE double][size: 4B BE uint32][N bytes]`` + — the form Axis-style camera datastreams use to ship one H.264 NAL unit + per observation. Payload is opaque to the SDK. + 2. **Fixed-width record**: ``[ts: 8B BE double][f32, f32, ...]`` — the form + PTZ-style scalar datastreams use. All fields are fixed-width; the parser + walks the schema in declared order. + +* **Schema-driven codec** (`SWEBinaryCodec`) — given a parsed + `SWEBinaryDatastreamRecordSchema`, walks the `record_encoding.members` list + in order, building a struct format string and (for block members) handling + the size-prefixed framing. Supports mixed records: any combination of + `BinaryComponentMember` (fixed-width scalar) and `BinaryBlockMember` + (size-prefixed opaque bytes), in any declared order. + +Block payloads are **opaque**: the codec strips/writes the 4-byte size prefix +but does NOT demux or transcode the payload bytes. A H.264 NAL unit goes in, +a H.264 NAL unit comes out. Per the SWE Common spec the `compression` field on +`BinaryBlockMember` is metadata for downstream consumers, not a directive +this codec acts on. + +References: +- CS API Part 2 §16.2.3 (BinaryEncoding) +- SWE Common 3 §6.4 (BinaryEncoding) +- docs/AXIS_CAMERA_FORMATS.md in the OGC code-sprint demo repo +""" + +from __future__ import annotations + +import struct +import time +from typing import Any, Dict, Mapping, Sequence, Tuple, Union + +from .encoding import BinaryBlockMember, BinaryComponentMember, BinaryEncoding +from .schema_datamodels import SWEBinaryDatastreamRecordSchema + +# OGC data-type URI → `struct` format character. +# Big-/little-endian is set on the format string prefix (see `_endian_prefix`), +# not here — these are the size+sign characters only. +# +# Sources: CS API Part 2 §16.2.3 cross-referenced with SWE Common 3 §6.4. +# Add additional URIs here as they appear on real wire payloads; raising on +# unknown is preferable to silently guessing. +DATATYPE_STRUCT_FMT: Dict[str, str] = { + "http://www.opengis.net/def/dataType/OGC/0/double": "d", + "http://www.opengis.net/def/dataType/OGC/0/float64": "d", + "http://www.opengis.net/def/dataType/OGC/0/float32": "f", + "http://www.opengis.net/def/dataType/OGC/0/signedByte": "b", + "http://www.opengis.net/def/dataType/OGC/0/signedShort": "h", + "http://www.opengis.net/def/dataType/OGC/0/signedInt": "i", + "http://www.opengis.net/def/dataType/OGC/0/signedLong": "q", + "http://www.opengis.net/def/dataType/OGC/0/unsignedByte": "B", + "http://www.opengis.net/def/dataType/OGC/0/unsignedShort": "H", + "http://www.opengis.net/def/dataType/OGC/0/unsignedInt": "I", + "http://www.opengis.net/def/dataType/OGC/0/unsignedLong": "Q", + "http://www.opengis.net/def/dataType/OGC/0/boolean": "?", +} + + +# Default OGC dataType URI per SWE Common scalar component class. Mirrors +# OSH's `SWEHelper.getDefaultBinaryEncoding()` (lib-ogc/swe-common-core, +# line ~530): when a BinaryEncoding isn't explicitly declared, OSH walks +# scalars and assigns the canonical wire type per component kind. The +# resulting BinaryEncoding.members list is what `BinaryDataWriter` then +# uses to pack/unpack bytes. +# +# Time defaults to `double` (epoch seconds in scientific contexts) — ISO 8601 +# strings can't go in a fixed-width slot. Callers who want sub-second +# precision past the float64 limit should declare an explicit dataType. +DEFAULT_DATATYPE_URI_FOR_SCALAR: Dict[str, str] = { + "QuantitySchema": "http://www.opengis.net/def/dataType/OGC/0/double", + "CountSchema": "http://www.opengis.net/def/dataType/OGC/0/signedInt", + "BooleanSchema": "http://www.opengis.net/def/dataType/OGC/0/boolean", + "TimeSchema": "http://www.opengis.net/def/dataType/OGC/0/double", +} + + +def _endian_prefix(byte_order: str) -> str: + """Map SWE `byteOrder` to a `struct` prefix. + + `struct` defaults to native byte order with native alignment when no + prefix is given; we always emit ``>`` or ``<`` to lock both the byte + order and standard sizes (no padding). + """ + if byte_order == "bigEndian": + return ">" + if byte_order == "littleEndian": + return "<" + raise ValueError(f"Unsupported byteOrder: {byte_order!r}") + + +# ----------------------------------------------------------------------------- +# Low-level helpers (no schema required) +# ----------------------------------------------------------------------------- + + +def encode_swe_binary_blob(payload: bytes, + ts: float | None = None) -> bytes: + """Encode one variable-size-block SWE binary record. + + Wire form: ``[8-byte BE double ts][4-byte BE uint32 size][N bytes payload]``. + + Use for video/image/opaque-codec datastreams whose schema declares a + single `Block` member (compression = H264, JPEG, etc.). The payload is + written verbatim; no codec interpretation. + + :param payload: Raw bytes to ship (e.g. one H.264 NAL unit). + :param ts: Unix epoch seconds for the observation timestamp; defaults + to ``time.time()`` at call time. + :returns: ``12 + len(payload)`` bytes ready to publish. + """ + t = ts if ts is not None else time.time() + return struct.pack(">dI", t, len(payload)) + payload + + +def decode_swe_binary_blob(buf: bytes) -> Tuple[float, bytes]: + """Decode one variable-size-block SWE binary record. + + Inverse of `encode_swe_binary_blob`. The trailing payload bytes are + returned opaquely — the caller is responsible for any codec-specific + decoding (H.264 NAL framing, JPEG marker parsing, etc.). + + :param buf: Bytes for exactly one record. Must be at least 12 bytes + (header) long, and at least ``12 + size`` bytes total. + :returns: ``(ts, payload)``. + :raises ValueError: if `buf` is shorter than the declared record. + """ + if len(buf) < 12: + raise ValueError( + f"SWE binary blob too short: got {len(buf)} bytes, need at least 12.") + ts, size = struct.unpack(">dI", buf[:12]) + if len(buf) < 12 + size: + raise ValueError( + f"SWE binary blob truncated: declared size {size}, " + f"have {len(buf) - 12} payload bytes.") + return ts, bytes(buf[12:12 + size]) + + +def encode_swe_binary_record(ts: float, *values: float, + fmt: str = "f") -> bytes: + """Encode one fixed-width SWE binary record (`[ts][f32, f32, ...]`). + + The ts column is always a big-endian 8-byte double. The remaining + columns share a single `struct` format character via `fmt` (default + ``"f"`` = float32). For mixed-column records use `SWEBinaryCodec`. + + :param ts: Unix epoch seconds. + :param values: Fixed-width scalar values in serialization order. + :param fmt: Single `struct` format char (e.g. ``"f"``, ``"d"``, + ``"i"``). Applied to every value. + :returns: ``8 + len(values) * struct.calcsize(fmt)`` bytes. + """ + return struct.pack(f">d{len(values)}{fmt}", ts, *values) + + +def decode_swe_binary_record(buf: bytes, + n_values: int, + fmt: str = "f") -> Tuple[float, ...]: + """Decode one fixed-width SWE binary record. + + Inverse of `encode_swe_binary_record`. + + :param buf: Bytes for exactly one record. + :param n_values: Number of trailing scalar columns. + :param fmt: Single `struct` format char shared by all trailing scalars. + :returns: ``(ts, *values)``. + """ + full = f">d{n_values}{fmt}" + expected = struct.calcsize(full) + if len(buf) < expected: + raise ValueError( + f"SWE binary record too short: got {len(buf)} bytes, " + f"need {expected} for fmt {full!r}.") + return struct.unpack(full, buf[:expected]) + + +# ----------------------------------------------------------------------------- +# Schema-driven codec +# ----------------------------------------------------------------------------- + + +def _member_key(ref: str) -> str: + """Extract the field name a `ref` resolves to. + + For SWE Common binary encodings the wire emits refs like ``/time`` or + ``/img``; we treat the last path segment as the dict key for encode/ + decode round-trips. Nested refs (e.g. ``/loc/lat``) are uncommon in + practice and fall back to the last segment too — open a ticket if + a real schema needs hierarchy preserved. + """ + if not ref: + raise ValueError("BinaryEncoding member has empty ref.") + return ref.rstrip("/").split("/")[-1] + + +class SWEBinaryCodec: + """Schema-driven encoder/decoder for `application/swe+binary` records. + + Constructed from a parsed `SWEBinaryDatastreamRecordSchema` (or its + inner `BinaryEncoding`). At construction time the codec compiles each + `Component` member into a `struct` format character; at encode/decode + time it walks `members` in order, packing fixed-width columns and + framing blocks with the 4-byte size prefix. + + Two methods: + + * :meth:`encode(values)` — values may be a `dict` keyed by field name + (the ``ref`` last segment) or a `Sequence` in declared member order. + Block members expect `bytes` (or `bytearray`/`memoryview`) values. + * :meth:`decode(buf)` — returns a dict keyed by field name. Block + values come back as `bytes`. + + The codec does not interpret block payloads; H.264 / JPEG / Protobuf / + etc. pass through verbatim. + """ + + def __init__( + self, + schema: Union[SWEBinaryDatastreamRecordSchema, BinaryEncoding], + ): + if isinstance(schema, SWEBinaryDatastreamRecordSchema): + encoding = schema.record_encoding + elif isinstance(schema, BinaryEncoding): + encoding = schema + else: + raise TypeError( + "SWEBinaryCodec expects an SWEBinaryDatastreamRecordSchema " + f"or BinaryEncoding, got {type(schema).__name__}.") + + if encoding.byte_encoding != "raw": + # base64 is in-spec but rarely seen on OSH wire payloads. + # Refuse loudly instead of silently mis-encoding. + raise NotImplementedError( + f"byteEncoding={encoding.byte_encoding!r} not supported; " + "only 'raw' is implemented. Open a ticket if you need base64." + ) + self._endian = _endian_prefix(encoding.byte_order) + self._members = list(encoding.members) + # Per-member compiled state: list of (kind, key, struct_fmt_or_None) + # kind ∈ {"component", "block"}. + self._compiled: list[tuple[str, str, str | None]] = [] + for i, m in enumerate(self._members): + key = _member_key(m.ref) + if isinstance(m, BinaryComponentMember): + fmt_char = DATATYPE_STRUCT_FMT.get(m.data_type) + if fmt_char is None: + raise ValueError( + f"BinaryEncoding.members[{i}]: unsupported dataType " + f"{m.data_type!r}. Add it to " + "oshconnect.swe_binary.DATATYPE_STRUCT_FMT.") + self._compiled.append(("component", key, fmt_char)) + elif isinstance(m, BinaryBlockMember): + self._compiled.append(("block", key, None)) + else: + raise TypeError( + f"BinaryEncoding.members[{i}]: unsupported member type " + f"{type(m).__name__}.") + + @property + def field_names(self) -> list[str]: + """Field names in declared member order. Useful for `Sequence` + callers that want to build a positional tuple.""" + return [key for _, key, _ in self._compiled] + + def encode(self, values: Union[Mapping[str, Any], Sequence[Any]]) -> bytes: + """Encode one record. Returns the wire bytes. + + :param values: A mapping keyed by member name OR a positional + sequence in declared member order. Component values must be + numeric (or bool for the ``boolean`` data type); block values + must be `bytes`-like. + """ + if isinstance(values, Mapping): + ordered = [values[key] for _, key, _ in self._compiled] + else: + ordered = list(values) + if len(ordered) != len(self._compiled): + raise ValueError( + f"SWEBinaryCodec.encode: expected {len(self._compiled)} " + f"values, got {len(ordered)}.") + out = bytearray() + for (kind, _, fmt_char), val in zip(self._compiled, ordered): + if kind == "component": + out += struct.pack(f"{self._endian}{fmt_char}", val) + else: # block + if not isinstance(val, (bytes, bytearray, memoryview)): + raise TypeError( + f"Block member expects bytes-like payload, got " + f"{type(val).__name__}.") + payload = bytes(val) + # 4-byte BE uint32 size prefix is implicit in SWE + # BinaryEncoding for Block members — see Axis demo doc. + out += struct.pack(f"{self._endian}I", len(payload)) + out += payload + return bytes(out) + + def decode(self, buf: bytes) -> Dict[str, Any]: + """Decode one record. Returns a dict keyed by field name. + + Trailing bytes after the declared record are ignored (callers + that want to demux a concatenated stream should slice on the + consumed length — exposed via :meth:`decode_with_offset`). + """ + result, _ = self.decode_with_offset(buf, offset=0) + return result + + def decode_with_offset(self, buf: bytes, offset: int = 0 + ) -> Tuple[Dict[str, Any], int]: + """Decode one record starting at `offset`. Returns ``(dict, new_offset)`` + so callers can walk a concatenated stream of records.""" + out: Dict[str, Any] = {} + i = offset + for kind, key, fmt_char in self._compiled: + if kind == "component": + full_fmt = f"{self._endian}{fmt_char}" + size = struct.calcsize(full_fmt) + if i + size > len(buf): + raise ValueError( + f"SWEBinaryCodec.decode: ran out of bytes while " + f"reading component {key!r} (need {size}, " + f"have {len(buf) - i}).") + (value,) = struct.unpack(full_fmt, buf[i:i + size]) + out[key] = value + i += size + else: # block + size_fmt = f"{self._endian}I" + if i + 4 > len(buf): + raise ValueError( + f"SWEBinaryCodec.decode: ran out of bytes while " + f"reading block size prefix for {key!r}.") + (size,) = struct.unpack(size_fmt, buf[i:i + 4]) + i += 4 + if i + size > len(buf): + raise ValueError( + f"SWEBinaryCodec.decode: block {key!r} truncated " + f"(declared {size}, have {len(buf) - i}).") + out[key] = bytes(buf[i:i + size]) + i += size + return out, i + + +# --------------------------------------------------------------------------- +# DataArray helpers — pack/unpack arrays of scalar values +# --------------------------------------------------------------------------- +# +# Ported from OSH core's `BinaryDataWriter` / `BinaryDataParser` behavior +# (see lib-ogc/swe-common-core in github.com/opensensorhub/osh-core): +# +# * Elements are packed tightly back-to-back per the declared dataType. No +# padding or alignment between elements. +# * Variable-size arrays carry a single uint32 count *before* the elements; +# fixed-size arrays carry just the elements. +# * Both layouts use the same big/little-endian convention as scalars. +# +# Scope: arrays of one scalar dataType. Arrays of records/vectors are +# legal SWE Common 3 and are supported by OSH, but require walking a +# member-list tree per element — left as a follow-up; the path is clear +# from the structure here. + + +def default_datatype_for_schema(schema) -> str: + """Return the OGC dataType URI OSH would assign by default to a SWE scalar. + + Mirrors `SWEHelper.getDefaultBinaryEncoding()` — when an array's + `element_type` is a scalar without an explicit BinaryEncoding member, + OSH picks ``float64`` for Quantity/Time, ``signedInt`` for Count, and + ``boolean`` for Boolean. Other component kinds (Text, Category) have + no fixed-width wire type and raise. + """ + cls_name = type(schema).__name__ + uri = DEFAULT_DATATYPE_URI_FOR_SCALAR.get(cls_name) + if uri is None: + raise TypeError( + f"default_datatype_for_schema: no canonical OGC dataType URI " + f"for {cls_name}. Supported scalar kinds: " + f"{sorted(DEFAULT_DATATYPE_URI_FOR_SCALAR)}. For variable-width " + "kinds (Text, Category) use SWE+JSON or carry the bytes via a " + "BinaryBlock member instead.") + return uri + + +def encode_swe_binary_scalar_array( + values, + data_type_uri: str, + *, + byte_order: str = "bigEndian", + variable_size: bool = False, +) -> bytes: + """Pack a list of scalars into SWE BinaryEncoding bytes. + + Wire layout (matches OSH ``BinaryDataWriter``): + + * ``variable_size=True`` -> ``[uint32 N (BE)][N scalars]`` + * ``variable_size=False`` -> just ``[N scalars]`` (caller knows N from + the schema's ``element_count.value``) + + All elements share one dataType URI. For mixed-type arrays (rare in + SWE Common 3) the caller is responsible for assembling the buffer + member-by-member. + + :param values: Sequence of Python values. Numeric for float/int + types, bool for the boolean type. + :param data_type_uri: A key in `DATATYPE_STRUCT_FMT`. + :param byte_order: ``"bigEndian"`` (default; OSH default) or + ``"littleEndian"``. + :param variable_size: Prepend a uint32 count if True. + """ + fmt_char = DATATYPE_STRUCT_FMT.get(data_type_uri) + if fmt_char is None: + raise ValueError( + f"encode_swe_binary_scalar_array: unsupported dataType " + f"{data_type_uri!r}. Add it to DATATYPE_STRUCT_FMT.") + endian = _endian_prefix(byte_order) + body = struct.pack(f"{endian}{len(values)}{fmt_char}", *values) + if variable_size: + return struct.pack(f"{endian}I", len(values)) + body + return body + + +def decode_swe_binary_scalar_array( + buf: bytes, + data_type_uri: str, + *, + byte_order: str = "bigEndian", + variable_size: bool = False, + element_count: int | None = None, +) -> list: + """Inverse of `encode_swe_binary_scalar_array`. + + :param buf: Bytes for exactly one array record (no trailing data). + :param data_type_uri: Same URI used at encode time. + :param byte_order: Same byte_order used at encode time. + :param variable_size: If True, read the leading uint32 count off the + buffer. If False, ``element_count`` must be provided (the schema + carries it via `element_count.value`). + :param element_count: Required when ``variable_size=False``. + """ + fmt_char = DATATYPE_STRUCT_FMT.get(data_type_uri) + if fmt_char is None: + raise ValueError( + f"decode_swe_binary_scalar_array: unsupported dataType " + f"{data_type_uri!r}.") + endian = _endian_prefix(byte_order) + offset = 0 + if variable_size: + if len(buf) < 4: + raise ValueError("Array buffer truncated before count prefix.") + (n,) = struct.unpack(f"{endian}I", buf[:4]) + offset = 4 + else: + if element_count is None: + raise ValueError( + "Fixed-size array decode requires element_count to be known " + "from the schema (got None).") + n = element_count + full_fmt = f"{endian}{n}{fmt_char}" + expected = struct.calcsize(full_fmt) + if len(buf) - offset < expected: + raise ValueError( + f"Array buffer too short: need {expected} bytes for {n} elements, " + f"have {len(buf) - offset}.") + out = list(struct.unpack(full_fmt, buf[offset:offset + expected])) + # struct's `?` returns native bool already; numeric URIs stay numeric. + return out diff --git a/src/oshconnect/swe_components.py b/src/oshconnect/swe_components.py index b4ea584..9aa9e4d 100644 --- a/src/oshconnect/swe_components.py +++ b/src/oshconnect/swe_components.py @@ -11,7 +11,7 @@ from numbers import Real from typing import Union, Any, Literal, Annotated -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator, SerializeAsAny +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from .csapi4py.constants import GeometryTypes from .api_utils import UCUMCode, URI @@ -78,13 +78,11 @@ def _fields_require_name(self): class VectorSchema(AnyComponentSchema): - label: str = Field(...) type: Literal["Vector"] = "Vector" definition: str = Field(...) reference_frame: str = Field(..., alias='referenceFrame') local_frame: str = Field(None, alias='localFrame') - # TODO: VERIFY might need to be moved further down when these are defined - coordinates: SerializeAsAny[Union[list[CountSchema], list[QuantitySchema], list[TimeSchema]]] = Field(...) + coordinates: Union[list[CountSchema], list[QuantitySchema], list[TimeSchema]] = Field(...) @model_validator(mode="after") def _coordinates_require_name(self): @@ -97,7 +95,12 @@ class DataArraySchema(AnyComponentSchema): type: Literal["DataArray"] = "DataArray" element_count: dict | str | CountSchema = Field(..., alias='elementCount') # Should type of Count element_type: "AnyComponent" = Field(..., alias='elementType') - encoding: str = Field(...) # TODO: implement an encodings class + # Optional in practice: when the parent schema carries a BinaryEncoding + # whose `members` reference this DataArray via a Block (e.g. an H.264 + # video frame), the record-level encoding overrides the array's wire + # shape and OSH omits this inner `encoding` field. See + # docs/osh_spec_deviations.md (dataarray-encoding-omitted-when-block-overridden). + encoding: str = Field(None) # TODO: implement an encodings class values: list = Field(None) @model_validator(mode="after") @@ -112,7 +115,9 @@ class MatrixSchema(AnyComponentSchema): # TODO: spec defines Matrix.elementType as a single component (allOf SoftNamedProperty + AnyComponent), # not a list. Cardinality fix is out of scope for the name-validator change. element_type: list["AnyComponent"] = Field(..., alias='elementType') - encoding: str = Field(...) # TODO: implement an encodings class + # Optional for the same reason as `DataArraySchema.encoding` — see that + # field's docstring and docs/osh_spec_deviations.md. + encoding: str = Field(None) # TODO: implement an encodings class values: list = Field(None) reference_frame: str = Field(None) local_frame: str = Field(None) @@ -128,7 +133,10 @@ class DataChoiceSchema(AnyComponentSchema): type: Literal["DataChoice"] = "DataChoice" updatable: bool = Field(False) optional: bool = Field(False) - choice_value: CategorySchema = Field(..., alias='choiceValue') # TODO: Might be called "choiceValues" + # `choiceValue` carries a runtime selection (which item is active) and is + # absent from schema responses emitted by OpenSensorHub. See + # `docs/osh_spec_deviations.md` (datachoice-schema-missing-choicevalue). + choice_value: CategorySchema = Field(None, alias='choiceValue') items: list["AnyComponent"] = Field(...) @model_validator(mode="after") @@ -139,7 +147,6 @@ def _items_require_name(self): class GeometrySchema(AnyComponentSchema): - label: str = Field(...) type: Literal["Geometry"] = "Geometry" updatable: bool = Field(False) optional: bool = Field(False) @@ -160,7 +167,6 @@ class GeometrySchema(AnyComponentSchema): class AnySimpleComponentSchema(AnyComponentSchema): - label: str = Field(...) description: str = Field(None) type: str = Field(...) updatable: bool = Field(False) @@ -273,3 +279,17 @@ class CategoryRangeSchema(AnySimpleComponentSchema): ], Field(discriminator="type"), ] + + +# Rebuild every container model that forward-references AnyComponent. +# Without this, pydantic leaves a `MockValSer` placeholder on the +# serializer side — `model_validate` upgrades the validator, but +# `model_dump`/`model_dump_json` raise +# `TypeError: 'MockValSer' object is not an instance of 'SchemaSerializer'`. +# Plain `model_rebuild()` is a no-op (the class reports `model_complete`), +# so `force=True` is required. +DataRecordSchema.model_rebuild(force=True) +VectorSchema.model_rebuild(force=True) +DataArraySchema.model_rebuild(force=True) +MatrixSchema.model_rebuild(force=True) +DataChoiceSchema.model_rebuild(force=True) diff --git a/src/oshconnect/swe_flatbuffers.py b/src/oshconnect/swe_flatbuffers.py new file mode 100644 index 0000000..e16880d --- /dev/null +++ b/src/oshconnect/swe_flatbuffers.py @@ -0,0 +1,75 @@ +# ============================================================================= +# Copyright (c) 2026 Georobotix Innovative Research +# Date: 2026/5/19 +# Author: Ian Patterson +# Contact Email: ian.patterson@georobotix.us +# ============================================================================= + +"""Runtime codec for the ``application/swe+flatbuffers`` wire format. + +**Status: blocked on an upstream FlatBuffers compiler limitation.** + +The SWE Common 3 FlatBuffers schemas (in the BinaryEncodings project) +declare ``BinaryEncoding.members`` as ``[BinaryMember]`` where +``BinaryMember`` is a union of ``BinaryComponent`` and ``BinaryBlock``. +``flatc --python`` rejects this with:: + + error: Vectors of unions are not yet supported in at least one of + the specified programming languages. + +Until ``flatc`` adds Python support for vector-of-union, we cannot +generate the SWE Common 3 Python bindings for FlatBuffers, and this +codec cannot do anything useful at runtime. The +`SWEFlatBuffersCodec` class is provided as a placeholder so the rest +of the SDK can already register, parse, and round-trip schemas that +name ``application/swe+flatbuffers`` — only the encode/decode +endpoints raise. + +See ``docs/osh_spec_deviations.md`` (``flatc-python-vector-of-union``) +and track upstream progress at https://github.com/google/flatbuffers. +""" + +from __future__ import annotations + +from typing import Any, Union + +from .schema_datamodels import SWEFlatBuffersDatastreamRecordSchema +from .swe_components import AnyComponentSchema + + +_BLOCKED_MESSAGE = ( + "SWEFlatBuffersCodec is currently blocked on a `flatc --python` " + "limitation: vectors of unions are not yet supported, and the SWE " + "Common 3 BinaryEncoding schema uses one. The schema class is " + "kept registered so the SDK can round-trip schemas naming this " + "format, but encode/decode cannot be implemented until the " + "FlatBuffers compiler grows the missing feature. See " + "docs/osh_spec_deviations.md (flatc-python-vector-of-union)." +) + + +class SWEFlatBuffersCodec: + """Placeholder for the FlatBuffers SWE codec. + + Constructed normally so callers don't have to special-case schema + registration — but :meth:`encode` and :meth:`decode` raise + ``NotImplementedError`` until the upstream toolchain limitation is + lifted. + """ + + def __init__( + self, + schema: Union[SWEFlatBuffersDatastreamRecordSchema, AnyComponentSchema], + ): + if not isinstance(schema, (SWEFlatBuffersDatastreamRecordSchema, AnyComponentSchema)): + raise TypeError( + "SWEFlatBuffersCodec expects an " + "SWEFlatBuffersDatastreamRecordSchema or AnyComponent schema, " + f"got {type(schema).__name__}.") + self._schema = schema + + def encode(self, _value: Any) -> bytes: + raise NotImplementedError(_BLOCKED_MESSAGE) + + def decode(self, _buf: bytes) -> Any: + raise NotImplementedError(_BLOCKED_MESSAGE) diff --git a/src/oshconnect/swe_protobuf.py b/src/oshconnect/swe_protobuf.py new file mode 100644 index 0000000..fd09fd4 --- /dev/null +++ b/src/oshconnect/swe_protobuf.py @@ -0,0 +1,634 @@ +# ============================================================================= +# Copyright (c) 2026 Georobotix Innovative Research +# Date: 2026/5/19 +# Author: Ian Patterson +# Contact Email: ian.patterson@georobotix.us +# ============================================================================= + +"""Runtime codec for the ``application/swe+proto`` wire format. + +Wire model +---------- +A single observation is a Protobuf-serialized ``DataRecord`` message from +the SWE Common 3 schemas in +https://github.com/tipatterson-dev/BinaryEncodings. The codec walks the +SWE-side record schema (a pydantic ``AnyComponent`` tree) and, for each +field, populates the matching variant of the protobuf +``AnyComponent`` oneof on the wire — for example, a SWE +``QuantitySchema`` field becomes a ``Quantity`` submessage; a +``TimeSchema`` field becomes a ``Time`` submessage; nested +``DataRecord``/``Vector``/``DataChoice``/``DataArray`` are recursive. + +Why a runtime codec instead of using ``google.protobuf.json_format``: +the SWE-side dict uses field *names* as keys and the values are bare +scalars (e.g. ``{"pan": -6.7}``), but on the wire each scalar lives +inside a typed protobuf submessage with extra structure (e.g. +``Quantity.value.number``). The runtime codec is the smallest piece +that knows both shapes. + +Bindings dependency +------------------- +The generated Python protobuf bindings are not bundled — install them +with the ``[protobuf]`` extra and produce them from the BinaryEncodings +repo: + +.. code-block:: bash + + pip install "oshconnect[protobuf]" + git clone https://github.com/tipatterson-dev/BinaryEncodings + cd BinaryEncodings && make protobuf PROTO_LANG=python + export PYTHONPATH="$PWD/gen/protobuf:$PYTHONPATH" + +The codec imports ``sweCommon3_pb2`` (and ``basic_types_pb2``, +``scalar_components_pb2``, ``encodings_pb2``) lazily so that +OSHConnect installs without the extra still work — the missing-import +error only fires when a swe+proto datastream is actually used. +""" + +from __future__ import annotations + +from typing import Any, Dict, Mapping, Union + +from .schema_datamodels import SWEProtobufDatastreamRecordSchema +from .swe_binary import ( + decode_swe_binary_scalar_array, default_datatype_for_schema, + encode_swe_binary_scalar_array, +) +from .swe_components import ( + AnyComponentSchema, BooleanSchema, CategorySchema, CountSchema, + DataArraySchema, DataChoiceSchema, DataRecordSchema, QuantitySchema, + TextSchema, TimeSchema, VectorSchema, +) + + +# Lazy-imported holders. Each entry is None until `_load_pb_modules` runs. +_pb: Any = None # sweCommon3_pb2 +_bt: Any = None # basic_types_pb2 +_sc: Any = None # scalar_components_pb2 + + +_INSTALL_HINT = ( + "Generated SWE Common 3 Protobuf bindings not found. Install with:\n" + " pip install 'oshconnect[protobuf]'\n" + "Then generate the bindings from the BinaryEncodings project:\n" + " git clone https://github.com/tipatterson-dev/BinaryEncodings\n" + " cd BinaryEncodings && make protobuf PROTO_LANG=python\n" + " export PYTHONPATH=\"$PWD/gen/protobuf:$PYTHONPATH\"" +) + + +def _load_pb_modules() -> None: + """Import the generated protobuf modules on first use. + + Separate function so the import error message can include the + install/generation hint instead of a bare ``ModuleNotFoundError``. + """ + global _pb, _bt, _sc + if _pb is not None: + return + try: + import sweCommon3_pb2 as pb + import basic_types_pb2 as bt + import scalar_components_pb2 as sc + except ImportError as exc: + raise ImportError(f"{_INSTALL_HINT}\nOriginal error: {exc}") from exc + _pb, _bt, _sc = pb, bt, sc + + +# Map a SWE Common component class to the (`AnyComponent` oneof field name, +# encode_func, decode_func) triple. Populated lazily in `_dispatch_table` +# because the protobuf modules aren't imported at import time. +_DISPATCH_TABLE: Dict[type, tuple] = {} + + +def _dispatch_table() -> Dict[type, tuple]: + if _DISPATCH_TABLE: + return _DISPATCH_TABLE + _load_pb_modules() + _DISPATCH_TABLE.update({ + BooleanSchema: ("boolean_component", _encode_boolean, _decode_boolean), + CountSchema: ("count_component", _encode_count, _decode_count), + QuantitySchema: ("quantity_component", _encode_quantity, _decode_quantity), + TimeSchema: ("time_component", _encode_time, _decode_time), + CategorySchema: ("category_component", _encode_category, _decode_category), + TextSchema: ("text_component", _encode_text, _decode_text), + DataRecordSchema: ("data_record", _encode_data_record, _decode_data_record), + VectorSchema: ("vector", _encode_vector, _decode_vector), + DataChoiceSchema: ("data_choice", _encode_data_choice, _decode_data_choice), + # DataArray uses the EncodedValues.inline_data path: pack the + # element values as SWE BinaryEncoding bytes (per the OSH + # reference impl in BinaryDataWriter.java) and stuff them in + # values.inline_data. Decode reads element_count + inline_data + # and reverses. Supports arrays of scalars (Quantity, Count, + # Boolean, Time); arrays of records/vectors raise. + DataArraySchema: ("data_array", _encode_data_array, _decode_data_array), + }) + return _DISPATCH_TABLE + + +# --------------------------------------------------------------------------- +# Scalar encoders / decoders. Each fills the leaf `value` slot on a freshly +# created protobuf submessage and returns it; decoders take a submessage and +# return the Python value. +# --------------------------------------------------------------------------- + + +def _encode_boolean(_schema: BooleanSchema, value: Any): + msg = _sc.Boolean() + msg.value = bool(value) + return msg + + +def _decode_boolean(msg) -> bool: + return bool(msg.value) + + +def _encode_count(_schema: CountSchema, value: Any): + msg = _sc.Count() + msg.value = int(value) + return msg + + +def _decode_count(msg) -> int: + return int(msg.value) + + +def _encode_quantity(_schema: QuantitySchema, value: Any): + msg = _sc.Quantity() + msg.value.number = float(value) + return msg + + +def _decode_quantity(msg) -> Union[float, str]: + """Decode a `Quantity` value. + + The encoder only writes ``NumberOrSpecial.number``, so messages this + SDK produced always come back as `float`. The `special` branch + (returning a `SpecialValue` enum name like ``"NA_N"``/``"POS_INFINITY"`` + as a string) is kept so the codec can also parse messages from other + SWE Common 3 implementations that *do* emit the special variants — + drop the branch when that interop requirement goes away. + """ + if msg.value.WhichOneof("kind") == "number": + return msg.value.number + return _bt.SpecialValue.Name(msg.value.special) + + +def _encode_time(_schema: TimeSchema, value: Any): + msg = _sc.Time() + if isinstance(value, str): + msg.value.date_time = value + elif isinstance(value, (int, float)): + msg.value.number = float(value) + else: + raise TypeError( + f"Time value must be ISO 8601 string or numeric epoch seconds, " + f"got {type(value).__name__}") + return msg + + +def _decode_time(msg) -> Union[str, float]: + kind = msg.value.WhichOneof("kind") + if kind == "date_time": + return msg.value.date_time + if kind == "number": + return msg.value.number + return _bt.SpecialValue.Name(msg.value.special) + + +def _encode_category(_schema: CategorySchema, value: Any): + msg = _sc.Category() + msg.value = str(value) + return msg + + +def _decode_category(msg) -> str: + return msg.value + + +def _encode_text(_schema: TextSchema, value: Any): + msg = _sc.Text() + msg.value = str(value) + return msg + + +def _decode_text(msg) -> str: + return msg.value + + +# --------------------------------------------------------------------------- +# Composite encoders / decoders. Recurse via `_dispatch_table`. +# --------------------------------------------------------------------------- + + +def _set_component_value(target_any_component, schema: AnyComponentSchema, value: Any) -> None: + """Populate one `AnyComponent` oneof in-place given a SWE schema + value.""" + table = _dispatch_table() + for schema_cls, (oneof_field, encoder, _) in table.items(): + if isinstance(schema, schema_cls): + sub_msg = encoder(schema, value) + getattr(target_any_component, oneof_field).CopyFrom(sub_msg) + return + raise TypeError( + f"swe_protobuf: unsupported component type {type(schema).__name__} " + f"({schema.__class__.__module__}). Supported: " + f"{sorted(s.__name__ for s in table)}") + + +def _get_component_value(any_component, schema: AnyComponentSchema) -> Any: + """Extract the Python value from an `AnyComponent` oneof using its SWE schema.""" + table = _dispatch_table() + oneof_set = any_component.WhichOneof("component") + if oneof_set is None: + raise ValueError("AnyComponent message is empty (no oneof variant set).") + for _, (oneof_field, _, decoder) in table.items(): + if oneof_field == oneof_set: + return decoder(getattr(any_component, oneof_field)) + raise TypeError( + f"swe_protobuf: protobuf carried oneof variant {oneof_set!r} but " + f"no decoder is registered for it.") + + +def _encode_data_record(schema: DataRecordSchema, value: Mapping[str, Any]): + """Build a protobuf `DataRecord` from a `{name: value}` mapping. + + Field order follows ``schema.fields`` so the wire bytes are deterministic. + Each value is encoded into the matching protobuf submessage by recursive + dispatch — nested DataRecords therefore work transparently. + """ + if not isinstance(value, Mapping): + raise TypeError( + f"DataRecord requires a mapping value, got {type(value).__name__}") + msg = _pb.DataRecord() + for field_schema in schema.fields: + if field_schema.name not in value: + raise KeyError( + f"DataRecord field {field_schema.name!r} missing from value mapping. " + f"Provided keys: {list(value.keys())}") + named = msg.fields.add() + named.name = field_schema.name + _set_component_value(named.component.inline, field_schema, value[field_schema.name]) + return msg + + +def _decode_data_record(msg) -> Dict[str, Any]: + out: Dict[str, Any] = {} + for named in msg.fields: + # Re-decoding requires the SWE schema — see SWEProtobufCodec.decode + # for the dispatcher that hands the schema back in. The schema-less + # path is only used for *nested* records where the parent's + # `_decode_*` already pairs each child with its schema. Here we look + # up via the inline component's oneof. + out[named.name] = _decode_any_component(named.component.inline) + return out + + +def _decode_any_component(any_component) -> Any: + """Schema-less decode of an AnyComponent — used for nested records where + the parent codec walks both trees in lockstep (see _decode_data_record). + """ + table = _dispatch_table() + oneof = any_component.WhichOneof("component") + if oneof is None: + return None + for _, (oneof_field, _, decoder) in table.items(): + if oneof_field == oneof: + sub = getattr(any_component, oneof_field) + return decoder(sub) + raise TypeError(f"Unknown AnyComponent oneof variant {oneof!r}.") + + +# `Vector.coordinates[i].coordinate` is a narrower `CoordinateComponent` +# oneof — not the full `AnyComponent`. Per SWE Common 3, only Count / +# Quantity / Time are valid vector coordinate types, so we dispatch on a +# small lookup rather than reusing `_set_component_value`. +_COORDINATE_ONEOF_MAP: Dict[type, tuple] = {} + + +def _coordinate_oneof_map() -> Dict[type, tuple]: + if _COORDINATE_ONEOF_MAP: + return _COORDINATE_ONEOF_MAP + _load_pb_modules() + _COORDINATE_ONEOF_MAP.update({ + QuantitySchema: ("quantity", _encode_quantity, _decode_quantity), + CountSchema: ("count", _encode_count, _decode_count), + TimeSchema: ("time", _encode_time, _decode_time), + }) + return _COORDINATE_ONEOF_MAP + + +def _encode_vector(schema: VectorSchema, value: Any): + """Build a protobuf `Vector` from a sequence (one entry per coordinate).""" + if not isinstance(value, (list, tuple)): + raise TypeError( + f"Vector requires a list/tuple value, got {type(value).__name__}") + if len(value) != len(schema.coordinates): + raise ValueError( + f"Vector expects {len(schema.coordinates)} coordinates, got {len(value)}.") + msg = _pb.Vector() + coord_map = _coordinate_oneof_map() + for coord_schema, v in zip(schema.coordinates, value): + named = msg.coordinates.add() + named.name = coord_schema.name + entry = next((e for cls, e in coord_map.items() + if isinstance(coord_schema, cls)), None) + if entry is None: + raise TypeError( + f"Vector.coordinates: unsupported coordinate type " + f"{type(coord_schema).__name__}; only Quantity, Count, " + f"and Time are valid per SWE Common 3.") + oneof_field, encoder, _ = entry + sub_msg = encoder(coord_schema, v) + getattr(named.coordinate, oneof_field).CopyFrom(sub_msg) + return msg + + +def _decode_vector(msg) -> list: + """Decode a `Vector` into a list — schema-less variant used only when the + parent codec has no schema to pair with. Otherwise see + `_schema_aware_decode`. + """ + coord_map = _coordinate_oneof_map() + out = [] + for named in msg.coordinates: + oneof = named.coordinate.WhichOneof("component") + for _, (oneof_field, _, decoder) in coord_map.items(): + if oneof_field == oneof: + out.append(decoder(getattr(named.coordinate, oneof_field))) + break + return out + + +def _encode_data_choice(schema: DataChoiceSchema, value: Any): + """Build a `DataChoice` from a ``(item_name, value)`` tuple or + ``{item_name: value}`` single-key mapping. The choice value (the + discriminator) goes into ``choice_value``.""" + if isinstance(value, Mapping): + if len(value) != 1: + raise ValueError( + f"DataChoice mapping must have exactly one key (the selected item), " + f"got {len(value)}: {list(value.keys())}") + item_name, item_value = next(iter(value.items())) + elif isinstance(value, tuple) and len(value) == 2: + item_name, item_value = value + else: + raise TypeError( + "DataChoice value must be a single-key mapping or (name, value) tuple, " + f"got {type(value).__name__}") + msg = _pb.DataChoice() + # Find the item schema by name + item_schemas = getattr(schema, "items", None) or [] + chosen = next((it for it in item_schemas if getattr(it, "name", None) == item_name), None) + if chosen is None: + raise KeyError( + f"DataChoice item {item_name!r} not found in schema. Available: " + f"{[it.name for it in item_schemas]}") + msg.choice_value.value = item_name + named = msg.items.add() + named.name = item_name + _set_component_value(named.component.inline, chosen, item_value) + return msg + + +def _decode_data_choice(msg) -> dict: + if not msg.items: + return {} + # Use the discriminator if present, else fall back to the only item. + chosen_name = msg.choice_value.value or msg.items[0].name + chosen = next((it for it in msg.items if it.name == chosen_name), msg.items[0]) + return {chosen.name: _decode_any_component(chosen.component.inline)} + + +# Mapping of SWE byteOrder string -> protobuf ByteOrder enum value. Set on +# first use because the enum lives in the lazy-imported encodings module. +def _pb_byte_order(byte_order: str): + import encodings_pb2 as enc + return { + "bigEndian": enc.ByteOrder.BYTE_ORDER_BIG_ENDIAN, + "littleEndian": enc.ByteOrder.BYTE_ORDER_LITTLE_ENDIAN, + }[byte_order] + + +def _encode_data_array(schema: DataArraySchema, value: Any): + """Build a protobuf `DataArray` from a list of element values. + + Ported from OSH's `BinaryDataWriter`: pack element values as SWE + BinaryEncoding bytes and stuff them in `values.inline_data`. The + accompanying `encoding` field carries the wire spec (byte order, + raw vs base64, the members list with one Component per element-type + scalar). `element_count.inline.value` carries the array length so + decoders don't have to inspect inline_data. + + Currently supports arrays of **one scalar type** — Quantity, Count, + Boolean, Time. Arrays of records/vectors are legal SWE Common 3 + (and OSH supports them) but require walking a per-element member + tree; see the follow-up note in `_dispatch_table()`. + """ + import encodings_pb2 as enc + if not isinstance(value, (list, tuple)): + raise TypeError( + f"DataArray requires a list/tuple, got {type(value).__name__}") + element_schema = schema.element_type + try: + data_type_uri = default_datatype_for_schema(element_schema) + except TypeError as exc: + raise TypeError( + f"DataArray.element_type {type(element_schema).__name__} is not " + "a supported scalar; arrays of records/vectors are not yet " + "implemented (only scalar element types — Quantity / Count / " + "Boolean / Time)." + ) from exc + + msg = _pb.DataArray() + msg.element_count.inline.value = len(value) + # Represent the element-type as a single NamedComponent — descriptive + # only; the actual values are packed into inline_data below. + elem_named = msg.element_type + elem_named.name = getattr(element_schema, "name", "element") + _set_component_value(elem_named.component.inline, element_schema, value[0] if value else 0) + + # Declare the wire spec used to pack inline_data. + msg.encoding.binary_encoding.byte_order = _pb_byte_order("bigEndian") + msg.encoding.binary_encoding.byte_encoding = enc.ByteEncodingMethod.BYTE_ENCODING_METHOD_RAW + member = msg.encoding.binary_encoding.members.add() + member.component.ref = f"/{elem_named.name}" + member.component.data_type = data_type_uri + + # Pack and stuff. No size prefix in inline_data itself — element_count + # carries N at the protobuf level, mirroring OSH's fixed-size layout. + msg.values.inline_data = encode_swe_binary_scalar_array( + list(value), data_type_uri, byte_order="bigEndian", variable_size=False) + return msg + + +def _decode_data_array(msg) -> list: + """Inverse of `_encode_data_array`. + + Drives off the protobuf message's own `element_count` + `encoding` + + `values.inline_data` — *not* the SWE-side schema — so messages + produced by other SWE Common 3 implementations decode the same as + ones produced by this codec. + """ + n = msg.element_count.inline.value or 0 + if n == 0: + return [] + members = list(msg.encoding.binary_encoding.members) + if not members: + raise ValueError( + "DataArray.encoding.binary_encoding.members is empty; cannot " + "decode inline_data without knowing the element wire type.") + # Scalar-only path: expect exactly one Component member. + first = members[0] + if first.WhichOneof("member") != "component": + raise NotImplementedError( + "DataArray decode: only scalar element types are supported; " + f"first member is {first.WhichOneof('member')!r}.") + data_type_uri = first.component.data_type + # Map protobuf ByteOrder enum back to the SWE string. + import encodings_pb2 as enc + bo = msg.encoding.binary_encoding.byte_order + byte_order = ("bigEndian" + if bo == enc.ByteOrder.BYTE_ORDER_BIG_ENDIAN + else "littleEndian") + return decode_swe_binary_scalar_array( + msg.values.inline_data, data_type_uri, + byte_order=byte_order, variable_size=False, element_count=n) + + +# --------------------------------------------------------------------------- +# Public codec class +# --------------------------------------------------------------------------- + + +class SWEProtobufCodec: + """Schema-driven encoder/decoder for ``application/swe+proto``. + + Construct from a parsed `SWEProtobufDatastreamRecordSchema` (or directly + from a SWE Common `AnyComponent` schema tree); call :meth:`encode` / + :meth:`decode` to round-trip records. + + Supported component types: ``Boolean``, ``Count``, ``Quantity``, + ``Time``, ``Category``, ``Text``, ``DataRecord`` (incl. nested), + ``Vector``, ``DataChoice``, and ``DataArray`` (of scalar element + types — Quantity, Count, Boolean, Time). ``Matrix``, ``Geometry``, + and the ``*Range`` variants — plus arrays of records/vectors — are + not yet implemented; encoding such a record raises ``TypeError``. + + DataArray wire format mirrors OSH's `BinaryDataWriter` reference + implementation (lib-ogc/swe-common-core): element values are packed + tightly back-to-back as SWE BinaryEncoding bytes (see + ``oshconnect.swe_binary.encode_swe_binary_scalar_array``) and + placed in ``values.inline_data``. The accompanying + ``encoding.binary_encoding`` carries the dataType URI used to pack + them, so the wire is self-describing. + """ + + def __init__( + self, + schema: Union[SWEProtobufDatastreamRecordSchema, AnyComponentSchema], + ): + _load_pb_modules() + if isinstance(schema, SWEProtobufDatastreamRecordSchema): + self._root_schema = schema.record_schema + elif isinstance(schema, AnyComponentSchema): + self._root_schema = schema + else: + raise TypeError( + "SWEProtobufCodec expects an SWEProtobufDatastreamRecordSchema " + f"or AnyComponent schema, got {type(schema).__name__}.") + + def encode(self, value: Any) -> bytes: + """Encode a single observation. ``value`` is whatever the root schema + expects — a mapping for DataRecord, a sequence for Vector / DataArray, + a scalar for a scalar-rooted schema.""" + table = _dispatch_table() + # Find the encoder for the root schema + for schema_cls, (_, encoder, _) in table.items(): + if isinstance(self._root_schema, schema_cls): + msg = encoder(self._root_schema, value) + return msg.SerializeToString() + raise TypeError( + f"swe_protobuf: cannot encode root schema of type " + f"{type(self._root_schema).__name__}; only DataRecord / Vector / " + f"DataChoice / DataArray and scalar types are currently wired up.") + + def decode(self, buf: bytes) -> Any: + """Decode bytes back into a Python value. Inverse of :meth:`encode`.""" + table = _dispatch_table() + # Determine the wire-side message type from the root schema, parse + # the bytes into it, then dispatch the schema-aware decoder. + for schema_cls, (_, _, decoder) in table.items(): + if isinstance(self._root_schema, schema_cls): + msg_cls = _pb_message_for_schema(schema_cls) + msg = msg_cls() + msg.ParseFromString(buf) + return _schema_aware_decode(self._root_schema, msg) + raise TypeError( + f"swe_protobuf: cannot decode root schema of type " + f"{type(self._root_schema).__name__}.") + + +def _pb_message_for_schema(schema_cls: type) -> type: + """Map a SWE schema class to its top-level protobuf message class.""" + return { + BooleanSchema: _sc.Boolean, + CountSchema: _sc.Count, + QuantitySchema: _sc.Quantity, + TimeSchema: _sc.Time, + CategorySchema: _sc.Category, + TextSchema: _sc.Text, + DataRecordSchema: _pb.DataRecord, + VectorSchema: _pb.Vector, + DataChoiceSchema: _pb.DataChoice, + DataArraySchema: _pb.DataArray, + }[schema_cls] + + +def _schema_aware_decode(schema: AnyComponentSchema, msg) -> Any: + """Decode a protobuf submessage using the matching SWE schema. + + Pairs with `_schema_aware_encode` so nested records keep their field + *names* (the schema-less decode loses them once you're past one layer). + """ + if isinstance(schema, DataRecordSchema): + out: Dict[str, Any] = {} + # Pair each named protobuf field with the schema field of the same + # name (don't trust positional alignment in case the encoder ever + # reorders). + by_name = {nf.name: nf for nf in msg.fields} + for field_schema in schema.fields: + named = by_name.get(field_schema.name) + if named is None: + continue + out[field_schema.name] = _schema_aware_decode( + field_schema, + getattr(named.component.inline, + _dispatch_table()[type(field_schema)][0]), + ) + return out + table = _dispatch_table() + for schema_cls, (_, _, decoder) in table.items(): + if isinstance(schema, schema_cls) and schema_cls not in ( + DataRecordSchema, VectorSchema, DataChoiceSchema, DataArraySchema): + return decoder(msg) + if isinstance(schema, VectorSchema): + # Coordinate dispatch is on CoordinateComponent (a narrower oneof + # than AnyComponent), so look up via _coordinate_oneof_map. + coord_map = _coordinate_oneof_map() + out = [] + for coord_schema, named in zip(schema.coordinates, msg.coordinates): + entry = next((e for cls, e in coord_map.items() + if isinstance(coord_schema, cls)), None) + if entry is None: + raise TypeError( + f"Vector.coordinates carries unsupported type " + f"{type(coord_schema).__name__}.") + oneof_field, _, decoder = entry + out.append(decoder(getattr(named.coordinate, oneof_field))) + return out + if isinstance(schema, DataChoiceSchema): + return _decode_data_choice(msg) + if isinstance(schema, DataArraySchema): + return _decode_data_array(msg) + raise TypeError( + f"_schema_aware_decode: unsupported schema type {type(schema).__name__}.") diff --git a/src/oshconnect/timemanagement.py b/src/oshconnect/timemanagement.py index 5b5286e..d30fd94 100644 --- a/src/oshconnect/timemanagement.py +++ b/src/oshconnect/timemanagement.py @@ -93,7 +93,7 @@ def time_to_iso(a_time: datetime | float) -> str: :return: """ if isinstance(a_time, float): - return datetime.fromtimestamp(a_time).strftime(TimeUtils.iso_format) + return datetime.fromtimestamp(a_time, tz=timezone.utc).strftime(TimeUtils.iso_format) elif isinstance(a_time, datetime): return a_time.strftime(TimeUtils.iso_format) diff --git a/tests/fixtures/fake_weather_schema_logical.json b/tests/fixtures/fake_weather_schema_logical.json new file mode 100644 index 0000000..006953f --- /dev/null +++ b/tests/fixtures/fake_weather_schema_logical.json @@ -0,0 +1,40 @@ +{ + "type": "object", + "title": "New Simulated Weather Sensor - weather", + "properties": { + "time": { + "title": "Sampling Time", + "type": "string", + "format": "date-time", + "x-ogc-definition": "http://www.opengis.net/def/property/OGC/0/SamplingTime", + "x-ogc-refFrame": "http://www.opengis.net/def/trs/BIPM/0/UTC", + "x-ogc-unit": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian" + }, + "temperature": { + "title": "Air Temperature", + "type": "number", + "x-ogc-definition": "http://mmisw.org/ont/cf/parameter/air_temperature", + "x-ogc-unit": "Cel" + }, + "pressure": { + "title": "Atmospheric Pressure", + "type": "number", + "x-ogc-definition": "http://mmisw.org/ont/cf/parameter/air_pressure", + "x-ogc-unit": "hPa" + }, + "windSpeed": { + "title": "Wind Speed", + "type": "number", + "x-ogc-definition": "http://mmisw.org/ont/cf/parameter/wind_speed", + "x-ogc-unit": "m/s" + }, + "windDirection": { + "title": "Wind Direction", + "type": "number", + "x-ogc-definition": "http://mmisw.org/ont/cf/parameter/wind_from_direction", + "x-ogc-refFrame": "http://www.opengis.net/def/cs/OGC/0/NED", + "x-ogc-axis": "z", + "x-ogc-unit": "deg" + } + } +} diff --git a/tests/fixtures/fake_weather_system_smljson.json b/tests/fixtures/fake_weather_system_smljson.json new file mode 100644 index 0000000..0e2c6a1 --- /dev/null +++ b/tests/fixtures/fake_weather_system_smljson.json @@ -0,0 +1,8 @@ +{ + "type": "PhysicalSystem", + "id": "fake-weather-001", + "uniqueId": "urn:osh:sensor:fakeweather:001", + "label": "Fake Weather Station", + "description": "A simulated weather station emitting temperature, pressure, wind speed, and wind direction.", + "definition": "http://www.w3.org/ns/sosa/Sensor" +} diff --git a/tests/test_api_helper.py b/tests/test_api_helper.py deleted file mode 100644 index 8d4330d..0000000 --- a/tests/test_api_helper.py +++ /dev/null @@ -1,18 +0,0 @@ -from oshconnect.csapi4py import APIHelper - - -def test_url_generation(): - helper = APIHelper(server_url='localhost', port=8282, protocol='http', username='admin', password='admin') - expected_url = "http://localhost:8282/sensorhub/api" - url = helper.get_api_root_url() - assert url == expected_url - expected_url = "ws://localhost:8282/sensorhub/api" - url = helper.get_api_root_url(socket=True) - assert url == expected_url - helper.set_protocol('https') - expected_url = "https://localhost:8282/sensorhub/api" - url = helper.get_api_root_url() - assert url == expected_url - expected_url = "wss://localhost:8282/sensorhub/api" - url = helper.get_api_root_url(socket=True) - assert url == expected_url diff --git a/tests/test_api_helpers_auth.py b/tests/test_api_helpers_auth.py new file mode 100644 index 0000000..510152a --- /dev/null +++ b/tests/test_api_helpers_auth.py @@ -0,0 +1,129 @@ +"""Auth and request-routing tests for the free helpers in +``oshconnect.api_helpers`` and the ``ConnectedSystemsRequestBuilder``. + +The helpers all funnel through ``ConnectedSystemAPIRequest.make_request`` +into ``oshconnect.csapi4py.request_wrappers``. Tests monkeypatch the +underlying ``requests.`` calls and capture the kwargs to verify +that ``auth`` and ``headers`` flow through as a tuple, not a leaked +``(None, None)`` placeholder. +""" +from __future__ import annotations + +from oshconnect import api_helpers +from oshconnect.csapi4py.con_sys_api import ConnectedSystemsRequestBuilder + + +class _MockResponse: + status_code = 200 + + def raise_for_status(self): + pass + + def json(self): + return {} + + +def _capture(into: dict): + def _f(url, params=None, headers=None, auth=None, **kwargs): + into["url"] = str(url) + into["params"] = params + into["headers"] = headers + into["auth"] = auth + return _MockResponse() + return _f + + +def test_with_basic_auth_no_op_when_none(): + builder = ConnectedSystemsRequestBuilder() + builder.with_basic_auth(None) + assert builder.api_request.auth is None + + +def test_with_basic_auth_sets_tuple(): + builder = ConnectedSystemsRequestBuilder() + builder.with_basic_auth(("alice", "pw")) + assert builder.api_request.auth == ("alice", "pw") + + +def test_with_auth_legacy_no_leaks_none_pair(): + """``with_auth(None, None)`` should not leak as Basic Auth.""" + builder = ConnectedSystemsRequestBuilder() + builder.with_auth(None, None) + assert builder.api_request.auth is None + + +def test_with_auth_legacy_sets_tuple_when_supplied(): + builder = ConnectedSystemsRequestBuilder() + builder.with_auth("u", "p") + assert builder.api_request.auth == ("u", "p") + + +def test_retrieve_datastream_schema_plumbs_auth(monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.get", _capture(captured), + ) + api_helpers.retrieve_datastream_schema( + "http://localhost:8282/sensorhub", "ds-id", + auth=("alice", "pw"), + obs_format="application/swe+json", + ) + assert captured["auth"] == ("alice", "pw") + assert captured["params"] == {"obsFormat": "application/swe+json"} + + +def test_retrieve_datastream_schema_omits_auth_when_none(monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.get", _capture(captured), + ) + api_helpers.retrieve_datastream_schema( + "http://localhost:8282/sensorhub", "ds-id", + ) + assert captured["auth"] is None + + +def test_retrieve_system_by_id_returns_response_not_dict(monkeypatch): + """Formerly bypassed ``make_request()`` and returned ``resp.json()``; + after standardization it returns the ``Response`` object like every + other helper.""" + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.get", _capture(captured), + ) + resp = api_helpers.retrieve_system_by_id( + "http://localhost:8282/sensorhub", "sys-id", + auth=("u", "p"), + ) + assert isinstance(resp, _MockResponse) + assert captured["auth"] == ("u", "p") + + +def test_create_new_systems_uses_auth_tuple(monkeypatch): + """Sanity check the migrated signature: ``auth=`` tuple flows through + POST as Basic Auth.""" + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.post", _capture(captured), + ) + api_helpers.create_new_systems( + "http://localhost:8282/sensorhub", + request_body={"name": "x"}, + auth=("u", "p"), + ) + assert captured["auth"] == ("u", "p") + + +def test_list_all_systems_in_collection_returns_response(monkeypatch): + """One of the formerly-raw-``requests`` helpers — confirms it now + routes through ``make_request()`` and returns a ``Response``.""" + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.get", _capture(captured), + ) + resp = api_helpers.list_all_systems_in_collection( + "http://localhost:8282/sensorhub", "col-id", + auth=("u", "p"), + ) + assert isinstance(resp, _MockResponse) + assert captured["auth"] == ("u", "p") diff --git a/tests/test_con_sys_api.py b/tests/test_con_sys_api.py new file mode 100644 index 0000000..52cec0a --- /dev/null +++ b/tests/test_con_sys_api.py @@ -0,0 +1,574 @@ +"""Unit tests for ``oshconnect.csapi4py.con_sys_api``. + +Covers ``ConnectedSystemAPIRequest`` (construction + ``make_request`` +dispatch) and ``ConnectedSystemsRequestBuilder`` (the fluent chain +used by the free helpers in ``api_helpers.py``). HTTP wrappers are +intercepted with ``monkeypatch.setattr`` against +``requests.{get,post,put,delete}`` so we exercise the dispatch +without standing up a server. + +Auth-handling on the builder gets dedicated coverage because the +``with_auth`` ↔ ``with_basic_auth`` interplay has a non-obvious +(None, None) carve-out that prevents leaking empty credentials. +""" +from __future__ import annotations + +import pytest + +from oshconnect.csapi4py.con_sys_api import ( + APIRequest, + ConnectedSystemAPIRequest, + ConnectedSystemsRequestBuilder, + DeleteRequest, + GetRequest, + PostRequest, + PutRequest, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +class _MockResponse: + status_code = 200 + ok = True + text = "" + headers = {} + + +def _capture(into: dict): + """Returns a ``requests.``-shaped callable that records every + kwarg the wrapper passes through.""" + def _f(url, params=None, headers=None, auth=None, data=None, json=None, **kwargs): + into["called"] = True + into["url"] = str(url) + into["params"] = params + into["headers"] = headers + into["auth"] = auth + into["data"] = data + into["json"] = json + return _MockResponse() + return _f + + +# --------------------------------------------------------------------------- +# ConnectedSystemAPIRequest +# --------------------------------------------------------------------------- + +class TestConnectedSystemAPIRequestConstruction: + def test_default_method_is_get(self): + req = ConnectedSystemAPIRequest() + assert req.request_method == "GET" + + def test_all_optional_fields_accept_none(self): + """All fields tolerate explicit ``None`` (regression guard for the + pydantic ``dict = Field(None)`` annotation bug). Pre-fix, passing + ``headers=None`` or ``params=None`` raised ``ValidationError``.""" + req = ConnectedSystemAPIRequest( + url=None, body=None, params=None, headers=None, auth=None, + ) + assert req.url is None + assert req.body is None + assert req.params is None + assert req.headers is None + assert req.auth is None + + def test_body_accepts_dict_or_str(self): + as_dict = ConnectedSystemAPIRequest(body={"k": "v"}) + as_str = ConnectedSystemAPIRequest(body='{"k": "v"}') + assert as_dict.body == {"k": "v"} + assert as_str.body == '{"k": "v"}' + + def test_auth_accepts_tuple_or_none(self): + with_creds = ConnectedSystemAPIRequest(auth=("u", "p")) + without_creds = ConnectedSystemAPIRequest(auth=None) + assert with_creds.auth == ("u", "p") + assert without_creds.auth is None + + +class TestMakeRequestDispatch: + """Each method routes to its matching ``requests.`` wrapper.""" + + def test_get_routes_to_requests_get(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.get", + _capture(captured), + ) + ConnectedSystemAPIRequest( + url="http://localhost:8282/sensorhub/api/systems", + request_method="GET", + params={"f": "json"}, + headers={"Accept": "application/json"}, + auth=("u", "p"), + ).make_request() + assert captured["called"] is True + assert captured["url"] == "http://localhost:8282/sensorhub/api/systems" + assert captured["params"] == {"f": "json"} + assert captured["headers"] == {"Accept": "application/json"} + assert captured["auth"] == ("u", "p") + + def test_post_routes_to_requests_post_with_body(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.post", + _capture(captured), + ) + ConnectedSystemAPIRequest( + url="http://localhost:8282/sensorhub/api/systems", + request_method="POST", + body='{"name": "x"}', + headers={"Content-Type": "application/json"}, + ).make_request() + assert captured["called"] is True + # str body lands in ``data``; dict body would land in ``json``. + assert captured["data"] == '{"name": "x"}' + assert captured["json"] is None + + def test_post_routes_dict_body_to_json(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.post", + _capture(captured), + ) + ConnectedSystemAPIRequest( + url="http://localhost:8282/sensorhub/api/systems", + request_method="POST", + body={"name": "x"}, + ).make_request() + assert captured["json"] == {"name": "x"} + assert captured["data"] is None + + def test_put_routes_to_requests_put(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.put", + _capture(captured), + ) + ConnectedSystemAPIRequest( + url="http://localhost:8282/sensorhub/api/systems/sys-1", + request_method="PUT", + body='{"name": "renamed"}', + ).make_request() + assert captured["called"] is True + assert captured["data"] == '{"name": "renamed"}' + + def test_delete_routes_to_requests_delete(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.delete", + _capture(captured), + ) + ConnectedSystemAPIRequest( + url="http://localhost:8282/sensorhub/api/systems/sys-1", + request_method="DELETE", + auth=("u", "p"), + ).make_request() + assert captured["called"] is True + assert captured["url"] == "http://localhost:8282/sensorhub/api/systems/sys-1" + assert captured["auth"] == ("u", "p") + + def test_invalid_method_raises_value_error(self): + req = ConnectedSystemAPIRequest( + url="http://localhost/api/systems", + request_method="PATCH", + ) + with pytest.raises(ValueError, match="Invalid request method"): + req.make_request() + + +class TestSendTimeValidation: + """``make_request`` validates request coherence before dispatch. + + ``url`` may be ``None`` during builder-style construction, but the + request must have a URL by send time. GET requests must not carry + a body; POST/PUT bodies are optional; DELETE bodies are tolerated. + """ + + def test_send_without_url_raises(self): + req = ConnectedSystemAPIRequest(request_method="GET") + with pytest.raises(ValueError, match="'url' is not set"): + req.make_request() + + def test_get_with_body_raises(self): + req = ConnectedSystemAPIRequest( + url="http://localhost/api/systems", + request_method="GET", + body={"oops": "bodies don't belong on GET"}, + ) + with pytest.raises(ValueError, match="GET requests must not carry a body"): + req.make_request() + + def test_get_without_body_dispatches(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.get", + _capture(captured), + ) + ConnectedSystemAPIRequest( + url="http://localhost/api/systems", + request_method="GET", + ).make_request() + assert captured["called"] is True + + def test_post_without_body_dispatches(self, monkeypatch): + """Bodyless POST is permitted (e.g., trigger-style endpoints).""" + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.post", + _capture(captured), + ) + ConnectedSystemAPIRequest( + url="http://localhost/api/systems/sys-1/actions/reset", + request_method="POST", + ).make_request() + assert captured["called"] is True + assert captured["json"] is None + assert captured["data"] is None + + def test_post_with_body_dispatches(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.post", + _capture(captured), + ) + ConnectedSystemAPIRequest( + url="http://localhost/api/systems", + request_method="POST", + body={"name": "x"}, + ).make_request() + assert captured["json"] == {"name": "x"} + + def test_put_with_body_dispatches(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.put", + _capture(captured), + ) + ConnectedSystemAPIRequest( + url="http://localhost/api/systems/sys-1", + request_method="PUT", + body='{"name": "renamed"}', + ).make_request() + assert captured["data"] == '{"name": "renamed"}' + + def test_delete_without_body_dispatches(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.delete", + _capture(captured), + ) + ConnectedSystemAPIRequest( + url="http://localhost/api/systems/sys-1", + request_method="DELETE", + ).make_request() + assert captured["called"] is True + + def test_delete_with_body_is_tolerated(self, monkeypatch): + """HTTP allows DELETE with a body (some APIs use it). We don't + enforce against it — just ensure dispatch still happens.""" + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.delete", + _capture(captured), + ) + ConnectedSystemAPIRequest( + url="http://localhost/api/systems/sys-1", + request_method="DELETE", + body={"reason": "cleanup"}, + ).make_request() + assert captured["called"] is True + + +# --------------------------------------------------------------------------- +# ConnectedSystemsRequestBuilder +# --------------------------------------------------------------------------- + +class TestBuilderFluentChain: + """Every ``with_*`` method must return ``self`` for chaining.""" + + @pytest.mark.parametrize("method, args", [ + ("with_api_url", ["http://localhost/api/systems"]), + ("with_server_url", ["http://localhost:8282"]), + ("with_api_root", ["api"]), + ("for_resource_type", ["systems"]), + ("with_resource_id", ["sys-1"]), + ("for_sub_resource_type", ["datastreams"]), + ("with_secondary_resource_id", ["ds-1"]), + ("with_request_body", ['{"name": "x"}']), + ("with_request_method", ["GET"]), + ("with_headers", [{"Accept": "application/json"}]), + ]) + def test_with_methods_return_self(self, method, args): + builder = ConnectedSystemsRequestBuilder() + result = getattr(builder, method)(*args) + assert result is builder + + def test_chained_call_threads_state(self): + """Smoke test: a representative chain produces the expected + request shape.""" + req = ( + ConnectedSystemsRequestBuilder() + .with_server_url("http://localhost:8282") + .with_api_root("api") + .for_resource_type("systems") + .with_resource_id("sys-1") + .build_url_from_base() + .with_request_method("GET") + .with_headers({"Accept": "application/json"}) + .with_basic_auth(("u", "p")) + .build() + ) + assert req.request_method == "GET" + assert req.headers == {"Accept": "application/json"} + assert req.auth == ("u", "p") + assert "/systems/sys-1" in str(req.url) + + +class TestBuilderURLConstruction: + def test_with_api_url_sets_url_directly(self): + builder = ConnectedSystemsRequestBuilder() + req = builder.with_api_url("http://example.com/api/x").build() + assert str(req.url) == "http://example.com/api/x" + + def test_build_url_from_base_uses_endpoint(self): + """``build_url_from_base`` composes ``base_url`` with whatever + ``Endpoint.create_endpoint()`` returns.""" + req = ( + ConnectedSystemsRequestBuilder() + .with_server_url("http://localhost:8282") + .with_api_root("api") + .for_resource_type("systems") + .with_resource_id("sys-1") + .build_url_from_base() + .build() + ) + assert str(req.url) == "http://localhost:8282/api/systems/sys-1" + + def test_build_url_threads_subcomponent_and_secondary_id(self): + req = ( + ConnectedSystemsRequestBuilder() + .with_server_url("http://localhost:8282") + .for_resource_type("systems") + .with_resource_id("sys-1") + .for_sub_resource_type("datastreams") + .with_secondary_resource_id("ds-1") + .build_url_from_base() + .build() + ) + assert str(req.url).endswith("/systems/sys-1/datastreams/ds-1") + + +class TestBuilderAuth: + """``with_auth`` and ``with_basic_auth`` have a non-obvious + (None, None) carve-out that prevents leaking empty credentials.""" + + def test_with_basic_auth_tuple_sets_auth(self): + req = ( + ConnectedSystemsRequestBuilder() + .with_basic_auth(("u", "p")) + .build() + ) + assert req.auth == ("u", "p") + + def test_with_basic_auth_none_is_noop(self): + """A no-op when ``None`` is passed — does not overwrite anything + previously set on the builder.""" + builder = ConnectedSystemsRequestBuilder() + builder.with_basic_auth(("u", "p")) + builder.with_basic_auth(None) + assert builder.api_request.auth == ("u", "p") + + def test_with_auth_both_none_does_not_set_credentials(self): + """Regression guard: ``with_auth(None, None)`` MUST NOT set + ``("None", "None")`` or any tuple at all on the request.""" + req = ( + ConnectedSystemsRequestBuilder() + .with_auth(None, None) + .build() + ) + assert req.auth is None + + def test_with_auth_real_credentials_sets_tuple(self): + req = ( + ConnectedSystemsRequestBuilder() + .with_auth("admin", "secret") + .build() + ) + assert req.auth == ("admin", "secret") + + def test_with_auth_partial_credentials_passes_through(self): + """A single populated half *does* set a tuple — the carve-out is + only for both being None. Documented behaviour, not a leak.""" + req = ( + ConnectedSystemsRequestBuilder() + .with_auth("admin", None) + .build() + ) + assert req.auth == ("admin", None) + + +class TestBuilderBuildAndReset: + def test_build_returns_api_request(self): + builder = ConnectedSystemsRequestBuilder() + builder.with_request_method("DELETE") + req = builder.build() + assert isinstance(req, ConnectedSystemAPIRequest) + assert req.request_method == "DELETE" + + def test_reset_clears_state(self): + builder = ConnectedSystemsRequestBuilder() + builder.with_request_method("DELETE") + builder.with_basic_auth(("u", "p")) + builder.for_resource_type("systems") + builder.reset() + assert builder.api_request.request_method == "GET" # back to default + assert builder.api_request.auth is None + # Endpoint state is reset too — re-building from base gives an + # empty path under the api root. + assert builder.endpoint.base_resource is None + + def test_reset_returns_self(self): + builder = ConnectedSystemsRequestBuilder() + assert builder.reset() is builder + + +# --------------------------------------------------------------------------- +# Per-method APIRequest subclasses (used by APIHelper) +# --------------------------------------------------------------------------- + +import pydantic + + +class TestAPIRequestBase: + """The base class itself isn't directly useful, but the contracts it + sets — required ``url``, common fields, abstract ``execute`` — are.""" + + def test_url_is_required_at_construction(self): + with pytest.raises(pydantic.ValidationError): + APIRequest() # type: ignore[call-arg] + + def test_base_execute_raises_not_implemented(self): + req = APIRequest(url="http://localhost/api/x") + with pytest.raises(NotImplementedError): + req.execute() + + +class TestGetRequest: + def test_url_required(self): + with pytest.raises(pydantic.ValidationError): + GetRequest() # type: ignore[call-arg] + + def test_no_body_field(self): + """The type system rejects ``body`` on GET — the field literally + isn't on the model. Catches misuse at construction.""" + assert "body" not in GetRequest.model_fields + + def test_execute_dispatches_to_get_request(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.get", + _capture(captured), + ) + GetRequest( + url="http://localhost/api/systems", + params={"f": "json"}, + headers={"Accept": "application/json"}, + auth=("u", "p"), + ).execute() + assert captured["url"] == "http://localhost/api/systems" + assert captured["params"] == {"f": "json"} + assert captured["headers"] == {"Accept": "application/json"} + assert captured["auth"] == ("u", "p") + + +class TestPostRequest: + def test_url_required(self): + with pytest.raises(pydantic.ValidationError): + PostRequest() # type: ignore[call-arg] + + def test_no_params_field(self): + """POST in this codebase carries body, not params — matches the + ``post_request`` wrapper signature.""" + assert "params" not in PostRequest.model_fields + + def test_execute_with_str_body_routes_to_data(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.post", + _capture(captured), + ) + PostRequest( + url="http://localhost/api/systems", + body='{"name": "x"}', + ).execute() + assert captured["data"] == '{"name": "x"}' + assert captured["json"] is None + + def test_execute_with_dict_body_routes_to_json(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.post", + _capture(captured), + ) + PostRequest( + url="http://localhost/api/systems", + body={"name": "x"}, + ).execute() + assert captured["json"] == {"name": "x"} + assert captured["data"] is None + + def test_execute_without_body_dispatches(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.post", + _capture(captured), + ) + PostRequest(url="http://localhost/api/x/actions/reset").execute() + assert captured["called"] is True + + +class TestPutRequest: + def test_url_required(self): + with pytest.raises(pydantic.ValidationError): + PutRequest() # type: ignore[call-arg] + + def test_no_params_field(self): + assert "params" not in PutRequest.model_fields + + def test_execute_with_body(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.put", + _capture(captured), + ) + PutRequest( + url="http://localhost/api/systems/sys-1", + body='{"name": "renamed"}', + ).execute() + assert captured["data"] == '{"name": "renamed"}' + + +class TestDeleteRequest: + def test_url_required(self): + with pytest.raises(pydantic.ValidationError): + DeleteRequest() # type: ignore[call-arg] + + def test_no_body_field(self): + """The wrapper doesn't pass a body to ``requests.delete``; we + match the wrapper rather than HTTP-allowed-but-unused shapes.""" + assert "body" not in DeleteRequest.model_fields + + def test_execute_dispatches_to_delete_request(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.delete", + _capture(captured), + ) + DeleteRequest( + url="http://localhost/api/systems/sys-1", + auth=("u", "p"), + ).execute() + assert captured["url"] == "http://localhost/api/systems/sys-1" + assert captured["auth"] == ("u", "p") diff --git a/tests/test_controlstream_insert_schema.py b/tests/test_controlstream_insert_schema.py new file mode 100644 index 0000000..bd58e8d --- /dev/null +++ b/tests/test_controlstream_insert_schema.py @@ -0,0 +1,139 @@ +"""Schema-variant tests for ``System.add_and_insert_control_stream``. + +The CS API offers two command-schema wire forms: + +- ``application/swe+json`` → SWE Common ``recordSchema`` plus a + ``JSONEncoding`` block. +- ``application/json`` → SWE Common ``parametersSchema``; no encoding. + +The previous implementation mixed them — emitting +``commandFormat: "application/swe+json"`` alongside ``parametersSchema``, +which violates both. These tests pin the expected on-the-wire shape per +``command_format`` so the bug can't regress. +""" +from __future__ import annotations + +import json + +import pytest + +from oshconnect import Node, System +from oshconnect.api_utils import URI, UCUMCode +from oshconnect.swe_components import DataRecordSchema, QuantitySchema, TimeSchema + + +class _MockResponse: + status_code = 201 + ok = True + text = "" + headers = {"Location": "http://localhost:8585/sensorhub/api/controlstreams/cs-new"} + + +def _capture_post(into: dict): + def _f(url, params=None, headers=None, auth=None, data=None, json=None, **kwargs): + into["url"] = str(url) + into["headers"] = headers + into["data"] = data + into["json"] = json + return _MockResponse() + return _f + + +def _record_schema() -> DataRecordSchema: + return DataRecordSchema( + name="counterControl", + label="Counter Control", + definition="http://example.org/CounterControl", + fields=[ + TimeSchema( + name="timestamp", + label="Timestamp", + definition="http://www.opengis.net/def/property/OGC/0/SamplingTime", + uom=URI(href="http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"), + ), + QuantitySchema( + name="setStep", + label="Set Step", + definition="http://example.org/SetStep", + uom=UCUMCode(code="1", label="step"), + ), + ], + ) + + +@pytest.fixture +def system(monkeypatch) -> System: + """A System wired to a Node, with the system already 'inserted' so + `_resource_id` is populated for the controlstream POST.""" + node = Node(protocol="http", address="localhost", port=8585) + sys = System( + label="Test System", urn="urn:test:sys:1", + parent_node=node, resource_id="sys-1", + ) + return sys + + +def _captured_body_json(captured: dict) -> dict: + """``request_wrappers.post_request`` chooses ``data=`` for str bodies and + ``json=`` for dicts. The control-stream path dumps to a JSON string, so + the body lands in ``data``.""" + body = captured.get("data") + if isinstance(body, (bytes, bytearray)): + body = body.decode("utf-8") + assert body is not None, f"no body captured: {captured}" + return json.loads(body) + + +def test_json_default_emits_parametersschema_no_encoding(system, monkeypatch): + """Default ``command_format='application/json'`` must produce the JSON + wire form: ``commandFormat: application/json`` plus ``parametersSchema``. + NOT ``recordSchema`` and NOT ``encoding``.""" + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.post", _capture_post(captured), + ) + + system.add_and_insert_control_stream(_record_schema()) + + body = _captured_body_json(captured) + schema = body["schema"] + assert schema["commandFormat"] == "application/json" + assert "parametersSchema" in schema, "JSON form must carry parametersSchema" + assert "recordSchema" not in schema, ( + "JSON form must NOT carry recordSchema (that's the SWE+JSON form)" + ) + assert "encoding" not in schema, ( + "JSON form has no encoding block — that's SWE+JSON only" + ) + + +def test_swejson_emits_recordschema_and_encoding(system, monkeypatch): + """`command_format='application/swe+json'` must produce the + spec-canonical wire form: ``commandFormat: application/swe+json`` plus + ``recordSchema`` plus ``encoding`` (JSONEncoding). NOT ``parametersSchema``.""" + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.post", _capture_post(captured), + ) + + system.add_and_insert_control_stream( + _record_schema(), command_format="application/swe+json", + ) + + body = _captured_body_json(captured) + schema = body["schema"] + assert schema["commandFormat"] == "application/swe+json" + assert "recordSchema" in schema, "SWE+JSON form must carry recordSchema" + assert "parametersSchema" not in schema, ( + "SWE+JSON form must NOT carry parametersSchema (that's the JSON form)" + ) + assert schema["encoding"]["type"] == "JSONEncoding" + + +def test_unsupported_command_format_raises(system): + """Anything other than the two supported formats is a programming + error — fail loudly rather than silently emit malformed JSON.""" + with pytest.raises(ValueError, match="Unsupported command_format"): + system.add_and_insert_control_stream( + _record_schema(), command_format="application/xml", + ) diff --git a/tests/test_csapi_serialization.py b/tests/test_csapi_serialization.py new file mode 100644 index 0000000..0d18457 --- /dev/null +++ b/tests/test_csapi_serialization.py @@ -0,0 +1,767 @@ +"""OGC standard-format (de)serialization for OSHConnect resources. + +Three layers per wrapper class: + + - Resource representation (System: SML+JSON / GeoJSON; + Datastream and ControlStream: application/json). + - Schema document (Datastream: SWE+JSON / OM+JSON; + ControlStream: SWE+JSON / JSON). + - Single record (one observation or one command). + +Tests are organized in those sections plus a generic "no behavior drift" +guard that confirms the new convenience methods produce the same output +as a raw `model_dump(by_alias=True, exclude_none=True, mode='json')`. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from oshconnect import Node +from oshconnect.resource_datamodels import ( + ControlStreamResource, + DatastreamResource, + ObservationResource, + SystemResource, +) +from oshconnect.schema_datamodels import ( + CommandJSON, + JSONCommandSchema, + OMJSONDatastreamRecordSchema, + LogicalDatastreamRecordSchema, + ObservationOMJSONInline, + SWEDatastreamRecordSchema, + SWEJSONCommandSchema, +) +from oshconnect.streamableresource import ControlStream, Datastream, System +from oshconnect.timemanagement import TimeInstant, TimePeriod + +FIXTURES_DIR = Path(__file__).parent / "fixtures" + + +@pytest.fixture +def node() -> Node: + return Node(protocol="http", address="localhost", port=8282) + + +# =========================================================================== +# System: SML+JSON, GeoJSON +# =========================================================================== + +def test_system_resource_to_smljson_round_trips(): + src = SystemResource(uid="urn:test:s1", label="S1", feature_type="PhysicalSystem") + dumped = src.to_smljson_dict() + assert dumped["type"] == "PhysicalSystem" + assert dumped["uniqueId"] == "urn:test:s1" + rebuilt = SystemResource.from_smljson_dict(dumped) + assert rebuilt.uid == "urn:test:s1" + + +def test_system_resource_to_geojson_round_trips(): + src = SystemResource( + uid="urn:test:s1", label="S1", feature_type="Feature", + properties={"name": "S1", "uid": "urn:test:s1"}, + ) + dumped = src.to_geojson_dict() + assert dumped["type"] == "Feature" + rebuilt = SystemResource.from_geojson_dict(dumped) + assert rebuilt.uid == "urn:test:s1" + + +def test_system_resource_from_csapi_autodetects_smljson(): + payload = {"type": "PhysicalSystem", "uniqueId": "urn:test:auto", + "label": "Auto"} + res = SystemResource.from_csapi_dict(payload) + assert res.feature_type == "PhysicalSystem" + assert res.uid == "urn:test:auto" + + +def test_system_resource_from_csapi_autodetects_geojson(): + payload = {"type": "Feature", "properties": {"name": "Auto", + "uid": "urn:test:auto"}} + res = SystemResource.from_csapi_dict(payload) + assert res.feature_type == "Feature" + assert res.properties["uid"] == "urn:test:auto" + + +def test_system_smljson_fixture_round_trips(): + raw = json.loads((FIXTURES_DIR / "fake_weather_system_smljson.json").read_text()) + res = SystemResource.from_smljson_dict(raw) + assert res.feature_type == "PhysicalSystem" + assert res.uid == "urn:osh:sensor:fakeweather:001" + re_dumped = res.to_smljson_dict() + # Required SML fields preserved + for key in ("type", "uniqueId", "label", "definition"): + assert key in re_dumped + + +def test_system_from_resource_attaches_to_node(node): + """`from_resource` is the canonical bridge from a parsed SystemResource + to a System wrapper, mirroring how Datastream/ControlStream's __init__ + accept their parsed resource directly.""" + res = SystemResource( + uid="urn:test:s1", label="S1", feature_type="PhysicalSystem", + system_id="ext-id-1", + ) + sys = System.from_resource(res, node) + assert isinstance(sys, System) + assert sys.urn == "urn:test:s1" + assert sys.label == "S1" + assert sys.get_parent_node() is node + assert sys.get_system_resource() is res + + +def test_system_from_resource_handles_geojson_shape(node): + """`from_resource` accepts a SystemResource regardless of which CS API + shape it was parsed from (GeoJSON vs SML+JSON). The properties-block + GeoJSON case routes name/uid through the `properties` dict.""" + res = SystemResource( + feature_type="Feature", + system_id="ext-id-2", + properties={"name": "GeoSys", "uid": "urn:test:geo"}, + ) + sys = System.from_resource(res, node) + assert sys.urn == "urn:test:geo" + assert sys.label == "GeoSys" + + +def test_system_full_chain_smljson_dict_to_resource_to_wrapper(node): + """End-to-end JSON -> SystemResource -> System chain. Format + conversion lives entirely on `SystemResource`; the wrapper only + knows how to bind a parsed resource to a parent node.""" + raw = json.loads((FIXTURES_DIR / "fake_weather_system_smljson.json").read_text()) + res = SystemResource.from_smljson_dict(raw) + sys = System.from_resource(res, node) + assert sys.urn == "urn:osh:sensor:fakeweather:001" + assert sys.get_system_resource() is res + + +def test_system_full_chain_geojson_dict_to_resource_to_wrapper(node): + """End-to-end GeoJSON variant of the chain.""" + raw = {"type": "Feature", "id": "geo-2", + "properties": {"name": "GeoSys2", "uid": "urn:test:geo:2"}} + res = SystemResource.from_geojson_dict(raw) + sys = System.from_resource(res, node) + assert sys.urn == "urn:test:geo:2" + assert sys.label == "GeoSys2" + + +# --------------------------------------------------------------------------- +# SML type preservation and non-mutation +# --------------------------------------------------------------------------- + +def test_to_smljson_preserves_non_default_feature_type(): + """A source whose SML type is ``PhysicalComponent`` (which OSH + surfaces as ``featureType: Sensor``) must round-trip through + ``to_smljson_dict`` without being collapsed back to + ``PhysicalSystem``. Regression guard for cross-node sync.""" + src = SystemResource(uid="urn:test:s1", label="S1", + feature_type="PhysicalComponent") + dumped = src.to_smljson_dict() + assert dumped["type"] == "PhysicalComponent" + + +def test_to_smljson_defaults_to_physical_system_when_unset(): + """When ``feature_type`` is unset, the SML body still gets a + sensible default so callers building a bare SystemResource + continue to produce a valid SML body.""" + src = SystemResource(uid="urn:test:s1", label="S1") + dumped = src.to_smljson_dict() + assert dumped["type"] == "PhysicalSystem" + + +def test_to_smljson_does_not_mutate_feature_type(): + """Pre-fix, ``to_smljson_dict`` set ``self.feature_type`` as a + side effect, which clobbered the source's SML kind. After the + fix, the model is untouched.""" + src = SystemResource(uid="urn:test:s1", label="S1", + feature_type="PhysicalComponent") + src.to_smljson_dict() + assert src.feature_type == "PhysicalComponent" + + +def test_to_geojson_always_emits_feature_without_mutating(): + """GeoJSON form requires ``type: Feature`` per spec, regardless + of ``feature_type`` on the model. The model itself stays + unmutated.""" + src = SystemResource(uid="urn:test:s1", label="S1", + feature_type="PhysicalComponent") + dumped = src.to_geojson_dict() + assert dumped["type"] == "Feature" + assert src.feature_type == "PhysicalComponent" + + +# --------------------------------------------------------------------------- +# System.to_system_resource preserves _underlying_resource +# --------------------------------------------------------------------------- + +def test_to_system_resource_preserves_full_underlying(node): + """When the wrapper carries a full ``_underlying_resource`` (e.g., + populated by discovery / ``from_csapi_dict``), the resource + rendered for POST keeps every field — not just uid/label/type.""" + raw = { + "type": "PhysicalComponent", + "id": "src-server-id-abc", + "uniqueId": "urn:test:source:1", + "label": "Source Sensor", + "description": "Original description", + "definition": "http://www.opengis.net/def/system", + "keywords": ["thermal", "imaging"], + } + res = SystemResource.from_smljson_dict(raw) + sys = System.from_resource(res, node) + + rendered = sys.to_system_resource() + + # Type preserved (was hardcoded to PhysicalSystem pre-fix). + assert rendered.feature_type == "PhysicalComponent" + # Other fields preserved (were silently dropped pre-fix). + assert rendered.description == "Original description" + assert rendered.definition == "http://www.opengis.net/def/system" + assert rendered.keywords == ["thermal", "imaging"] + + +def test_to_system_resource_thin_shell_for_freshly_constructed(node): + """A System constructed from scratch (no parsed resource) still + produces a sensible thin shell with default ``PhysicalSystem`` + type — backward-compat with code that doesn't go through + discovery.""" + sys = System(label="Fresh", urn="urn:test:fresh:1", + parent_node=node) + rendered = sys.to_system_resource() + assert rendered.feature_type == "PhysicalSystem" + assert rendered.uid == "urn:test:fresh:1" + + +def test_system_name_property_is_deprecated_alias_for_label(node): + """The wrapper-level `name` field was always populated from the + same wire string as `label` — the OGC CS API only carries one + display string per system. `System.name` is now a deprecated + alias for `.label`; reading or writing it emits + ``DeprecationWarning`` but still works for one-release back-compat. + """ + sys = System(label="Original", urn="urn:test:dep:1", parent_node=node) + + # Reading: returns label, emits deprecation warning. + with pytest.warns(DeprecationWarning, match=r"System\.name.*deprecated"): + assert sys.name == "Original" + + # Writing: sets label, emits deprecation warning. + with pytest.warns(DeprecationWarning, match=r"System\.name.*deprecated"): + sys.name = "Renamed" + assert sys.label == "Renamed" + + +def test_system_init_with_name_kwarg_routes_to_label_with_warning(node): + """Passing the deprecated `name=` kwarg to `System(...)` populates + `label` (when `label` is not also given) and emits a deprecation + warning. When both are provided, `label` wins and `name` is dropped. + """ + with pytest.warns(DeprecationWarning, match=r"System\(name=\.\.\.\)"): + sys = System(name="LegacyOnly", urn="urn:test:dep:2", parent_node=node) + assert sys.label == "LegacyOnly" + + with pytest.warns(DeprecationWarning): + sys2 = System(label="Wins", name="Loses", + urn="urn:test:dep:3", parent_node=node) + assert sys2.label == "Wins" + + +# --------------------------------------------------------------------------- +# insert_self strips server-assigned fields from the POST body +# --------------------------------------------------------------------------- + +class _MockResponse: + status_code = 201 + ok = True + text = "" + headers = {"Location": "http://localhost:8282/sensorhub/api/systems/dest-id-xyz"} + + +def _capture_post(into: dict): + def _f(url, params=None, headers=None, auth=None, data=None, json=None, **kwargs): + into["url"] = str(url) + into["data"] = data + into["json"] = json + return _MockResponse() + return _f + + +def test_insert_self_strips_id_and_links_from_body(node, monkeypatch): + """When re-POSTing a discovered system to a destination node, the + source's server-assigned ``id`` and ``links`` must not leak into + the body — the destination assigns its own. Regression guard for + cross-node sync.""" + raw = { + "type": "PhysicalComponent", + "id": "source-side-id", + "uniqueId": "urn:test:source:1", + "label": "Source Sensor", + "links": [{"href": "http://source.example/extra", "rel": "alternate"}], + } + res = SystemResource.from_smljson_dict(raw) + sys = System.from_resource(res, node) + + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.post", + _capture_post(captured), + ) + + sys.insert_self() + + body = json.loads(captured["data"]) + # Source-assigned identifiers must NOT be present in the POST body. + assert "id" not in body, ( + "POST body must not carry source's server-assigned id" + ) + assert "links" not in body, ( + "POST body must not carry source's server-assigned links" + ) + # But the SML kind from the source IS preserved. + assert body["type"] == "PhysicalComponent" + assert body["uniqueId"] == "urn:test:source:1" + # Wrapper picked up the destination's id from the Location header. + assert sys._resource_id == "dest-id-xyz" + + +# =========================================================================== +# Datastream: resource representation, schema document, observations +# =========================================================================== + +def _datastream_resource_from_swejson_fixture() -> DatastreamResource: + raw = json.loads((FIXTURES_DIR / "fake_weather_schema_swejson.json").read_text()) + schema = SWEDatastreamRecordSchema.from_swejson_dict(raw) + return DatastreamResource( + ds_id="ds-001", name="weather", + valid_time=TimePeriod(start="2025-01-01T00:00:00Z", + end="2099-12-31T00:00:00Z"), + record_schema=schema, + ) + + +def test_datastream_resource_round_trips(): + src = _datastream_resource_from_swejson_fixture() + dumped = src.to_csapi_dict() + assert dumped["id"] == "ds-001" + assert dumped["schema"]["obsFormat"] == "application/swe+json" + rebuilt = DatastreamResource.from_csapi_dict(dumped) + assert rebuilt.ds_id == "ds-001" + + +def test_datastream_schema_accessible_via_underlying_resource(node): + """Schema rendering lives on the schema model, not on the wrapper. + Users reach it via `ds._underlying_resource.record_schema.to_*_dict()`.""" + raw = json.loads((FIXTURES_DIR / "fake_weather_schema_swejson.json").read_text()) + schema = SWEDatastreamRecordSchema.from_swejson_dict(raw) + ds = Datastream(parent_node=node, datastream_resource=DatastreamResource( + ds_id="ds-1", name="w", + valid_time=TimePeriod(start="2025-01-01T00:00:00Z", + end="2099-12-31T00:00:00Z"), + record_schema=schema, + )) + out = ds._underlying_resource.record_schema.to_swejson_dict() + assert out["obsFormat"] == "application/swe+json" + assert out["recordSchema"]["name"] == "weather" + + +def test_swe_datastream_schema_model_dump_json_directly(): + """Regression: prior to the SerializeAsAny -> discriminated-union + migration, calling `model_dump_json` on a parsed `SWEDatastreamRecordSchema` + raised `MockValSer is not an instance of SchemaSerializer` because + pydantic deferred building the serializer for the recursive + `list["AnyComponent"]` forward refs and never replaced the placeholder. + + The fix combines (a) discriminated unions on `obs_format`/`command_format` + eliminating SerializeAsAny on the resource models, and (b) explicit + `model_rebuild(force=True)` on every container. Both `model_dump` + and `model_dump_json` must now succeed on a parsed schema.""" + raw = json.loads((FIXTURES_DIR / "fake_weather_schema_swejson.json").read_text()) + schema = SWEDatastreamRecordSchema.from_swejson_dict(raw) + + py = schema.model_dump(by_alias=True, exclude_none=True) + assert py["obsFormat"] == "application/swe+json" + assert py["recordSchema"]["name"] == "weather" + + js = schema.model_dump_json(by_alias=True, exclude_none=True) + assert json.loads(js)["obsFormat"] == "application/swe+json" + + +def test_datastream_resource_with_populated_schema_dumps_via_broker_path(): + """Regression covering the broker's exact path: validate a + DatastreamResource, populate `record_schema` with a parsed SWE+JSON + schema, then `model_dump_json(by_alias=True, exclude_none=True)`. + Pre-fix this raised `MockValSer is not an instance of SchemaSerializer`.""" + schema_raw = json.loads( + (FIXTURES_DIR / "fake_weather_schema_swejson.json").read_text() + ) + ds = DatastreamResource( + ds_id="ds-001", name="weather", + valid_time=TimePeriod(start="2025-01-01T00:00:00Z", + end="2099-12-31T00:00:00Z"), + ) + ds.record_schema = SWEDatastreamRecordSchema.from_swejson_dict(schema_raw) + + payload = ds.model_dump_json(by_alias=True, exclude_none=True) + parsed = json.loads(payload) + assert parsed["id"] == "ds-001" + assert parsed["schema"]["obsFormat"] == "application/swe+json" + assert parsed["schema"]["recordSchema"]["type"] == "DataRecord" + + # Round-trip: the discriminated union picks the right arm on parse-back. + rebuilt = DatastreamResource.model_validate_json(payload) + assert isinstance(rebuilt.record_schema, SWEDatastreamRecordSchema) + assert rebuilt.record_schema.obs_format == "application/swe+json" + + +def test_datastream_resource_dispatches_to_omjson_arm_via_discriminator(): + """The `AnyDatastreamRecordSchema` discriminated union must route + `obsFormat: application/om+json` payloads to `OMJSONDatastreamRecordSchema`.""" + om_schema_raw = json.loads( + (FIXTURES_DIR / "fake_weather_schema_omjson.json").read_text() + ) + om = OMJSONDatastreamRecordSchema.from_omjson_dict(om_schema_raw) + ds = DatastreamResource( + ds_id="ds-om", name="weather-om", + valid_time=TimePeriod(start="2025-01-01T00:00:00Z", + end="2099-12-31T00:00:00Z"), + record_schema=om, + ) + payload = ds.model_dump_json(by_alias=True, exclude_none=True) + rebuilt = DatastreamResource.model_validate_json(payload) + assert isinstance(rebuilt.record_schema, OMJSONDatastreamRecordSchema) + assert rebuilt.record_schema.obs_format in ( + "application/om+json", "application/json", + ) + + +def test_controlstream_resource_with_populated_schema_dumps_via_broker_path(): + """Same broker-path regression for the control-stream side.""" + cmd_schema = JSONCommandSchema( + command_format="application/json", + params_schema={ + "type": "DataRecord", + "name": "cmd", + "label": "Cmd", + "fields": [ + {"type": "Quantity", "name": "speed", "label": "Speed", + "definition": "http://example.org/speed", + "uom": {"code": "m/s"}}, + ], + }, + ) + cs = ControlStreamResource( + cs_id="cs-1", name="set-speed", + command_schema=cmd_schema, + ) + + payload = cs.model_dump_json(by_alias=True, exclude_none=True) + parsed = json.loads(payload) + assert parsed["schema"]["commandFormat"] == "application/json" + assert parsed["schema"]["parametersSchema"]["name"] == "cmd" + + rebuilt = ControlStreamResource.model_validate_json(payload) + assert isinstance(rebuilt.command_schema, JSONCommandSchema) + assert rebuilt.command_schema.command_format == "application/json" + + +# --------------------------------------------------------------------------- +# Logical schema (OSH's `obsFormat=logical` shape) +# --------------------------------------------------------------------------- + +def test_logical_schema_round_trips_from_fixture(): + """Parse OSH's logical schema (JSON Schema with x-ogc-* extensions), + re-dump it, and confirm the round-trip preserves all fields.""" + raw = json.loads((FIXTURES_DIR / "fake_weather_schema_logical.json").read_text()) + schema = LogicalDatastreamRecordSchema.from_logical_dict(raw) + + assert schema.type == "object" + assert schema.title == "New Simulated Weather Sensor - weather" + assert set(schema.properties.keys()) == { + "time", "temperature", "pressure", "windSpeed", "windDirection" + } + + # OGC extensions parsed via aliases + temp = schema.properties["temperature"] + assert temp.type == "number" + assert temp.title == "Air Temperature" + assert temp.ogc_definition == "http://mmisw.org/ont/cf/parameter/air_temperature" + assert temp.ogc_unit == "Cel" + + time = schema.properties["time"] + assert time.type == "string" + assert time.format == "date-time" + assert time.ogc_ref_frame == "http://www.opengis.net/def/trs/BIPM/0/UTC" + + wind_dir = schema.properties["windDirection"] + assert wind_dir.ogc_axis == "z" + + # Round-trip: dump back into wire form, deep-equal to fixture + dumped = schema.to_logical_dict() + assert dumped == raw + + +def test_logical_schema_distinct_shape_from_swe_and_om(): + """The logical fixture is structurally distinct: no `obsFormat` + envelope and no `recordSchema` wrapper. Parsing SWE+JSON / OM+JSON + fixtures through `LogicalDatastreamRecordSchema` (which requires the + JSON-Schema-style ``type`` + ``properties``) fails — confirming the + three models target genuinely different shapes.""" + swe_raw = json.loads((FIXTURES_DIR / "fake_weather_schema_swejson.json").read_text()) + om_raw = json.loads((FIXTURES_DIR / "fake_weather_schema_omjson.json").read_text()) + with pytest.raises(ValidationError): + LogicalDatastreamRecordSchema.from_logical_dict(swe_raw) + with pytest.raises(ValidationError): + LogicalDatastreamRecordSchema.from_logical_dict(om_raw) + + +def test_logical_schema_permissive_extra_fields(): + """JSON Schema fields we haven't modeled (description, default, + minimum, maximum, etc.) are accepted via ``extra='allow'`` so future + OSH additions don't break parsing.""" + raw = { + "type": "object", + "title": "Test", + "description": "extra field, not modeled", + "properties": { + "x": { + "type": "number", + "minimum": 0, + "maximum": 100, + "default": 50, + "x-ogc-unit": "Cel", + }, + }, + } + schema = LogicalDatastreamRecordSchema.from_logical_dict(raw) + # Extra fields preserved on the model + dumped = schema.to_logical_dict() + assert dumped["description"] == "extra field, not modeled" + assert dumped["properties"]["x"]["minimum"] == 0 + + +def test_retrieve_datastream_schema_logical_obsformat(monkeypatch): + """Schema retrieval lives as a free function in + ``oshconnect.api_helpers``, not on ``Datastream``. Callers pick the + schema variant via the ``obs_format`` query param. Verify the URL, + ``?obsFormat=logical`` query, and that the body parses as + ``LogicalDatastreamRecordSchema``. + """ + from oshconnect.api_helpers import retrieve_datastream_schema + + raw = json.loads((FIXTURES_DIR / "fake_weather_schema_logical.json").read_text()) + + captured = {} + + class _MockResponse: + status_code = 200 + + def raise_for_status(self): + pass + + def json(self): + return raw + + def _mock_get(url, params=None, headers=None, auth=None, **kwargs): + captured["url"] = str(url) + captured["params"] = params + captured["auth"] = auth + return _MockResponse() + + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.get", _mock_get, + ) + + resp = retrieve_datastream_schema( + "http://localhost:8282/sensorhub", "038s1ic7k460", + obs_format="logical", + ) + schema = LogicalDatastreamRecordSchema.from_logical_dict(resp.json()) + + assert isinstance(schema, LogicalDatastreamRecordSchema) + assert schema.title == "New Simulated Weather Sensor - weather" + assert captured["url"].endswith("/datastreams/038s1ic7k460/schema") + assert captured["params"] == {"obsFormat": "logical"} + + +def test_retrieve_datastream_schema_swejson_obsformat(monkeypatch): + """Symmetric to the logical-format test: SWE+JSON variant goes + through the same ``retrieve_datastream_schema`` helper, picked via + ``obs_format='application/swe+json'``. The body parses as + ``SWEDatastreamRecordSchema``. + """ + from oshconnect.api_helpers import retrieve_datastream_schema + + raw = json.loads((FIXTURES_DIR / "fake_weather_schema_swejson.json").read_text()) + + captured = {} + + class _MockResponse: + status_code = 200 + + def raise_for_status(self): + pass + + def json(self): + return raw + + def _mock_get(url, params=None, headers=None, auth=None, **kwargs): + captured["params"] = params + return _MockResponse() + + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.get", _mock_get, + ) + + resp = retrieve_datastream_schema( + "http://localhost:8282/sensorhub", "ds-x", + obs_format="application/swe+json", + ) + schema = SWEDatastreamRecordSchema.from_swejson_dict(resp.json()) + assert isinstance(schema, SWEDatastreamRecordSchema) + assert captured["params"] == {"obsFormat": "application/swe+json"} + + +def test_observation_to_omjson_round_trips(): + src_time = TimeInstant.from_string("2025-06-01T12:00:00Z") + obs = ObservationResource( + result={"temperature": 22.5}, + result_time=src_time, + ) + dumped = obs.to_omjson_dict(datastream_id="ds-1") + assert dumped["datastream@id"] == "ds-1" + assert dumped["result"] == {"temperature": 22.5} + # resultTime is rendered via TimeUtils.time_to_iso (microsecond ISO 8601 with Z). + assert dumped["resultTime"].startswith("2025-06-01T12:00:00") + assert dumped["resultTime"].endswith("Z") + rebuilt = ObservationResource.from_omjson_dict(dumped) + assert rebuilt.result == {"temperature": 22.5} + assert rebuilt.result_time.epoch_time == src_time.epoch_time + + +def test_observation_to_swejson_round_trips(): + obs = ObservationResource( + result={"time": "2025-06-01T12:00:00Z", "temperature": 22.5}, + result_time=TimeInstant.from_string("2025-06-01T12:00:00Z"), + ) + payload = obs.to_swejson_dict() + assert payload == {"time": "2025-06-01T12:00:00Z", "temperature": 22.5} + rebuilt = ObservationResource.from_swejson_dict( + payload, result_time="2025-06-01T12:00:00Z" + ) + assert rebuilt.result == payload + + +def test_observation_omjson_caller_supplies_datastream_id(): + """ObservationResource.to_omjson_dict accepts an optional `datastream_id` + so the caller (typically wrapping code that knows the parent datastream) + can stamp it onto the OM+JSON envelope.""" + obs = ObservationResource( + result={"temperature": 22.5}, + result_time=TimeInstant.from_string("2025-06-01T12:00:00Z"), + ) + payload = obs.to_omjson_dict(datastream_id="ds-99") + assert payload["datastream@id"] == "ds-99" + # When omitted, no datastream@id key in the output. + payload_bare = obs.to_omjson_dict() + assert "datastream@id" not in payload_bare + + +# =========================================================================== +# ControlStream: resource representation, schema, commands +# =========================================================================== + +def _controlstream_resource_with_json_schema() -> ControlStreamResource: + schema = JSONCommandSchema.from_json_dict({ + "commandFormat": "application/json", + "parametersSchema": { + "type": "DataRecord", "name": "params", + "fields": [{ + "type": "Quantity", "name": "speed", "label": "Speed", + "definition": "http://example.org/speed", "uom": {"code": "m/s"}, + }], + }, + }) + return ControlStreamResource( + cs_id="cs-001", name="motor", input_name="motor", + valid_time=TimePeriod(start="2025-01-01T00:00:00Z", + end="2099-12-31T00:00:00Z"), + command_schema=schema, + ) + + +def test_controlstream_resource_round_trips(): + src = _controlstream_resource_with_json_schema() + dumped = src.to_csapi_dict() + assert dumped["id"] == "cs-001" + assert dumped["schema"]["commandFormat"] == "application/json" + rebuilt = ControlStreamResource.from_csapi_dict(dumped) + assert rebuilt.cs_id == "cs-001" + + +def test_controlstream_schema_accessible_via_underlying_resource(node): + """Command schema rendering lives on the schema model. Users reach + it via `cs._underlying_resource.command_schema.to_json_dict()`.""" + cs_resource = _controlstream_resource_with_json_schema() + cs = ControlStream(node=node, controlstream_resource=cs_resource) + out = cs._underlying_resource.command_schema.to_json_dict() + assert out["commandFormat"] == "application/json" + assert out["parametersSchema"]["name"] == "params" + + +def test_command_json_round_trips(): + src = CommandJSON(control_id="cs-1", sender="me", params={"x": 1}) + dumped = src.to_csapi_dict() + assert dumped["control@id"] == "cs-1" + # CS API Part 2 / OSH expects "parameters" on the wire, not "params". + # OSH returns 500 if the body uses "params" (verified against a live + # 8282 instance against the controllable-counter sample sensor). + assert dumped["parameters"] == {"x": 1} + assert "params" not in dumped, ( + "CommandJSON must serialize as 'parameters' (CS API Part 2), not 'params'" + ) + rebuilt = CommandJSON.from_csapi_dict(dumped) + assert rebuilt.params == {"x": 1} + + +# =========================================================================== +# Generic: no behavior drift from raw model_dump +# =========================================================================== + +@pytest.mark.parametrize("build,method", [ + (lambda: SystemResource(uid="urn:test:1", label="X", feature_type="PhysicalSystem"), + "to_smljson_dict"), + (lambda: _datastream_resource_from_swejson_fixture(), "to_csapi_dict"), + (lambda: _controlstream_resource_with_json_schema(), "to_csapi_dict"), +]) +def test_resource_to_csapi_matches_raw_model_dump(build, method): + instance = build() + new_way = getattr(instance, method)() + raw_way = instance.model_dump(by_alias=True, exclude_none=True, mode='json') + assert new_way == raw_way + + +# =========================================================================== +# Deprecation warnings on the old factories +# =========================================================================== + +def test_system_from_system_resource_emits_deprecation_warning(node): + raw = json.loads((FIXTURES_DIR / "fake_weather_system_smljson.json").read_text()) + res = SystemResource.from_smljson_dict(raw) + with pytest.warns(DeprecationWarning, match="from_resource"): + sys = System.from_system_resource(res, node) + assert sys.urn == "urn:osh:sensor:fakeweather:001" + + +def test_datastream_from_resource_emits_deprecation_warning(node): + ds_resource = DatastreamResource( + ds_id="ds-1", name="w", + valid_time=TimePeriod(start="2025-01-01T00:00:00Z", + end="2099-12-31T00:00:00Z"), + ) + with pytest.warns(DeprecationWarning, match="constructor"): + ds = Datastream.from_resource(ds_resource, node) + assert ds.get_id() == "ds-1" diff --git a/tests/test_datastore.py b/tests/test_datastore.py index 5edfb0f..e95e288 100644 --- a/tests/test_datastore.py +++ b/tests/test_datastore.py @@ -45,7 +45,6 @@ def make_node(sm: SessionManager = None) -> Node: def make_system(node: Node) -> System: return System( - name="test_system", label="Test System", urn="urn:test:sensors:sys1", parent_node=node, @@ -141,7 +140,7 @@ def test_save_and_load_system(self): loaded = store.load_system(system_id, node) assert loaded is not None - assert loaded.name == system.name + assert loaded.label == system.label assert loaded.urn == system.urn def test_load_missing_system_returns_none(self): @@ -156,7 +155,6 @@ def test_load_systems_for_node(self): node = make_node(sm) sys1 = make_system(node) sys2 = System( - name="system_two", label="System Two", urn="urn:test:sensors:sys2", parent_node=node, @@ -167,9 +165,9 @@ def test_load_systems_for_node(self): systems = store.load_systems_for_node(node.get_id(), node) assert len(systems) == 2 - names = {s.name for s in systems} - assert "test_system" in names - assert "system_two" in names + labels = {s.label for s in systems} + assert "Test System" in labels + assert "System Two" in labels def test_delete_system(self): store = SQLiteDataStore(":memory:") @@ -255,7 +253,7 @@ def test_save_all_and_load_all(self): sm = SessionManager() node = make_node(sm) system = make_system(node) - node.add_new_system(system) + node.add_system(system) store.save_all([node]) nodes = store.load_all(session_manager=sm) @@ -264,7 +262,7 @@ def test_save_all_and_load_all(self): loaded_node = nodes[0] assert loaded_node.get_id() == node.get_id() assert len(loaded_node.systems()) == 1 - assert loaded_node.systems()[0].name == system.name + assert loaded_node.systems()[0].label == system.label def test_save_all_empty_node_list(self): store = SQLiteDataStore(":memory:") @@ -304,7 +302,7 @@ def test_save_to_store_and_load_from_store(self): assert len(app2._nodes) == 1 assert len(app2._systems) == 1 - assert app2._systems[0].name == system.name + assert app2._systems[0].label == system.label def test_save_to_store_no_datastore_raises(self): app = OSHConnect(name="no-store-app") diff --git a/tests/test_default_api_helpers.py b/tests/test_default_api_helpers.py new file mode 100644 index 0000000..99b8462 --- /dev/null +++ b/tests/test_default_api_helpers.py @@ -0,0 +1,526 @@ +"""Unit tests for ``oshconnect.csapi4py.default_api_helpers``. + +Covers the two module-level helpers (``determine_parent_type``, +``resource_type_to_endpoint``) and every public method on the +``APIHelper`` dataclass. HTTP methods are exercised with +``monkeypatch`` against ``requests.{get,post,put,delete}`` (same +pattern as ``tests/test_controlstream_insert_schema.py``) so the +constructed URL, body, headers, and auth tuple can be inspected +without standing up a server. + +The ``update_resource`` and ``delete_resource`` tests specifically +pin the resource ID into the URL — regression lock-in for the bug +where those methods were dropping ``res_id`` on the floor. +""" +from __future__ import annotations + +import pytest + +from oshconnect.csapi4py.constants import APIResourceTypes +from oshconnect.csapi4py.default_api_helpers import ( + APIHelper, + determine_parent_type, + resource_type_to_endpoint, +) + + +# --------------------------------------------------------------------------- +# Module-level helpers +# --------------------------------------------------------------------------- + +class TestDetermineParentType: + """``determine_parent_type`` is a static mapping; lock every branch.""" + + @pytest.mark.parametrize("res_type, expected_parent", [ + (APIResourceTypes.SYSTEM, APIResourceTypes.SYSTEM), + (APIResourceTypes.CONTROL_CHANNEL, APIResourceTypes.SYSTEM), + (APIResourceTypes.DATASTREAM, APIResourceTypes.SYSTEM), + (APIResourceTypes.SYSTEM_EVENT, APIResourceTypes.SYSTEM), + (APIResourceTypes.SAMPLING_FEATURE, APIResourceTypes.SYSTEM), + (APIResourceTypes.COMMAND, APIResourceTypes.CONTROL_CHANNEL), + (APIResourceTypes.OBSERVATION, APIResourceTypes.DATASTREAM), + ]) + def test_known_parent_mappings(self, res_type, expected_parent): + assert determine_parent_type(res_type) is expected_parent + + @pytest.mark.parametrize("res_type", [ + APIResourceTypes.COLLECTION, + APIResourceTypes.PROCEDURE, + APIResourceTypes.PROPERTY, + APIResourceTypes.SYSTEM_HISTORY, + APIResourceTypes.DEPLOYMENT, + APIResourceTypes.STATUS, # falls into default branch + APIResourceTypes.SCHEMA, # falls into default branch + ]) + def test_top_level_or_default_returns_none(self, res_type): + assert determine_parent_type(res_type) is None + + +class TestResourceTypeToEndpoint: + """``resource_type_to_endpoint`` is also a static mapping; lock every branch.""" + + @pytest.mark.parametrize("res_type, expected", [ + (APIResourceTypes.SYSTEM, "systems"), + (APIResourceTypes.COLLECTION, "collections"), + (APIResourceTypes.CONTROL_CHANNEL, "controlstreams"), + (APIResourceTypes.COMMAND, "commands"), + (APIResourceTypes.DATASTREAM, "datastreams"), + (APIResourceTypes.OBSERVATION, "observations"), + (APIResourceTypes.SYSTEM_EVENT, "systemEvents"), + (APIResourceTypes.SAMPLING_FEATURE, "samplingFeatures"), + (APIResourceTypes.PROCEDURE, "procedures"), + (APIResourceTypes.PROPERTY, "properties"), + (APIResourceTypes.SYSTEM_HISTORY, "history"), + (APIResourceTypes.DEPLOYMENT, "deployments"), + (APIResourceTypes.STATUS, "status"), + (APIResourceTypes.SCHEMA, "schema"), + ]) + def test_known_endpoint_mappings(self, res_type, expected): + assert resource_type_to_endpoint(res_type) == expected + + def test_collection_parent_overrides_to_items(self): + """When the parent type is COLLECTION, the endpoint becomes + ``items`` regardless of the inner ``res_type``.""" + assert resource_type_to_endpoint( + APIResourceTypes.SYSTEM, parent_type=APIResourceTypes.COLLECTION, + ) == "items" + + def test_unknown_type_raises(self): + """The default branch raises ``ValueError`` for an unmapped type. + ``None`` falls through every match arm and trips the default.""" + with pytest.raises(ValueError, match="Invalid resource type"): + resource_type_to_endpoint(None) + + +# --------------------------------------------------------------------------- +# APIHelper utility methods (no HTTP) +# --------------------------------------------------------------------------- + +def _make_helper(**overrides) -> APIHelper: + defaults = dict( + server_url="localhost", + port=8282, + protocol="http", + server_root="sensorhub", + api_root="api", + mqtt_topic_root=None, + username=None, + password=None, + user_auth=False, + ) + defaults.update(overrides) + return APIHelper(**defaults) + + +class TestAPIHelperBaseURLs: + def test_get_base_url_http_with_port(self): + helper = _make_helper(protocol="http", port=8282) + assert helper.get_base_url() == "http://localhost:8282" + + def test_get_base_url_https_with_port(self): + helper = _make_helper(protocol="https", port=8443) + assert helper.get_base_url() == "https://localhost:8443" + + def test_get_base_url_no_port(self): + helper = _make_helper(protocol="https", port=None) + assert helper.get_base_url() == "https://localhost" + + def test_get_base_url_socket_upgrades_http_to_ws(self): + helper = _make_helper(protocol="http", port=8282) + assert helper.get_base_url(socket=True) == "ws://localhost:8282" + + def test_get_base_url_socket_upgrades_https_to_wss(self): + helper = _make_helper(protocol="https", port=8443) + assert helper.get_base_url(socket=True) == "wss://localhost:8443" + + def test_get_api_root_url_composes_full_path(self): + helper = _make_helper(server_root="sensorhub", api_root="api") + assert helper.get_api_root_url() == "http://localhost:8282/sensorhub/api" + + def test_get_api_root_url_socket_variant(self): + helper = _make_helper(protocol="https", port=8443) + assert ( + helper.get_api_root_url(socket=True) + == "wss://localhost:8443/sensorhub/api" + ) + + +class TestAPIHelperAuth: + def test_get_helper_auth_when_unauthenticated(self): + helper = _make_helper(user_auth=False) + assert helper.get_helper_auth() is None + + def test_get_helper_auth_returns_credential_tuple(self): + helper = _make_helper(username="admin", password="secret", user_auth=True) + assert helper.get_helper_auth() == ("admin", "secret") + + +class TestAPIHelperProtocol: + @pytest.mark.parametrize("protocol", ["http", "https", "ws", "wss"]) + def test_set_protocol_accepts_valid(self, protocol): + helper = _make_helper() + helper.set_protocol(protocol) + assert helper.protocol == protocol + + def test_set_protocol_rejects_invalid(self): + helper = _make_helper() + with pytest.raises(ValueError): + helper.set_protocol("ftp") + + +class TestAPIHelperMQTTRoot: + def test_falls_back_to_api_root_when_unset(self): + helper = _make_helper(api_root="api", mqtt_topic_root=None) + assert helper.get_mqtt_root() == "api" + + def test_uses_explicit_mqtt_topic_root_when_set(self): + helper = _make_helper(api_root="api", mqtt_topic_root="osh/mqtt") + assert helper.get_mqtt_root() == "osh/mqtt" + + +class TestConstructURL: + """``construct_url`` is the low-level URL builder. Cover its four shapes.""" + + def test_top_level_resource_no_id(self): + helper = _make_helper() + url = helper.construct_url( + resource_type=None, subresource_id=None, + subresource_type=APIResourceTypes.SYSTEM, resource_id=None, + ) + assert url == "http://localhost:8282/sensorhub/api/systems" + + def test_top_level_resource_with_id(self): + helper = _make_helper() + url = helper.construct_url( + resource_type=None, subresource_id="sys-1", + subresource_type=APIResourceTypes.SYSTEM, resource_id=None, + ) + assert url == "http://localhost:8282/sensorhub/api/systems/sys-1" + + def test_subresource_collection(self): + helper = _make_helper() + url = helper.construct_url( + resource_type=APIResourceTypes.SYSTEM, subresource_id=None, + subresource_type=APIResourceTypes.DATASTREAM, resource_id="sys-1", + ) + assert ( + url == "http://localhost:8282/sensorhub/api/systems/sys-1/datastreams" + ) + + def test_subresource_with_id(self): + helper = _make_helper() + url = helper.construct_url( + resource_type=APIResourceTypes.SYSTEM, subresource_id="ds-1", + subresource_type=APIResourceTypes.DATASTREAM, resource_id="sys-1", + ) + assert ( + url + == "http://localhost:8282/sensorhub/api/systems/sys-1/datastreams/ds-1" + ) + + def test_for_socket_uses_ws_scheme(self): + helper = _make_helper(protocol="http") + url = helper.construct_url( + resource_type=None, subresource_id=None, + subresource_type=APIResourceTypes.SYSTEM, resource_id=None, + for_socket=True, + ) + assert url.startswith("ws://localhost:8282") + + +class TestResourceURLResolver: + def test_none_subresource_type_raises(self): + helper = _make_helper() + with pytest.raises(ValueError, match="valid APIResourceType"): + helper.resource_url_resolver(subresource_type=None) + + def test_collection_as_subresource_of_collection_raises(self): + helper = _make_helper() + with pytest.raises(ValueError, match="not sub-resources of other collections"): + helper.resource_url_resolver( + subresource_type=APIResourceTypes.COLLECTION, + from_collection=True, + ) + + def test_top_level_resolves_to_collection_endpoint(self): + helper = _make_helper() + url = helper.resource_url_resolver( + subresource_type=APIResourceTypes.SYSTEM, + ) + assert url.endswith("/systems") + + def test_subresource_resolves_with_parent_id(self): + helper = _make_helper() + url = helper.resource_url_resolver( + subresource_type=APIResourceTypes.DATASTREAM, + subresource_id="ds-1", + resource_id="sys-1", + ) + assert url.endswith("/systems/sys-1/datastreams/ds-1") + + def test_collection_membership_uses_items_endpoint(self): + """When ``from_collection=True`` and a parent ID is provided, + the parent endpoint becomes ``collections/`` and the + sub-resource endpoint becomes ``items``.""" + helper = _make_helper() + url = helper.resource_url_resolver( + subresource_type=APIResourceTypes.SYSTEM, + resource_id="col-1", + from_collection=True, + ) + assert url.endswith("/collections/col-1/items") + + +class TestGetMQTTTopic: + def test_data_topic_for_datastream_observations(self): + helper = _make_helper() + topic = helper.get_mqtt_topic( + resource_type=APIResourceTypes.DATASTREAM, + subresource_type=APIResourceTypes.OBSERVATION, + resource_id="ds-1", + data_topic=True, + ) + assert topic == "api/datastreams/ds-1/observations:data" + + def test_event_topic_omits_data_suffix(self): + helper = _make_helper() + topic = helper.get_mqtt_topic( + resource_type=APIResourceTypes.SYSTEM, + subresource_type=APIResourceTypes.DATASTREAM, + resource_id="sys-1", + data_topic=False, + ) + assert topic == "api/systems/sys-1/datastreams" + + def test_topic_uses_mqtt_topic_root_when_set(self): + helper = _make_helper(mqtt_topic_root="osh/mqtt") + topic = helper.get_mqtt_topic( + resource_type=APIResourceTypes.DATASTREAM, + subresource_type=APIResourceTypes.OBSERVATION, + resource_id="ds-1", + data_topic=True, + ) + assert topic.startswith("osh/mqtt/") + + def test_topic_with_subresource_id_appends_after_data_suffix(self): + helper = _make_helper() + topic = helper.get_mqtt_topic( + resource_type=APIResourceTypes.DATASTREAM, + subresource_type=APIResourceTypes.OBSERVATION, + resource_id="ds-1", + subresource_id="obs-1", + data_topic=True, + ) + assert topic == "api/datastreams/ds-1/observations:data/obs-1" + + +# --------------------------------------------------------------------------- +# APIHelper HTTP methods (monkeypatch requests.{verb}) +# --------------------------------------------------------------------------- + +class _MockResponse: + status_code = 200 + ok = True + text = "" + headers = {"Location": "http://localhost:8282/sensorhub/api/systems/new-id"} + + +def _capture(into: dict): + """Returns a callable usable for monkeypatching ``requests.``; + captures every kwarg the wrapper passes through and returns a + successful response.""" + def _f(url, params=None, headers=None, auth=None, data=None, json=None, **kwargs): + into["url"] = str(url) + into["params"] = params + into["headers"] = headers + into["auth"] = auth + into["data"] = data + into["json"] = json + return _MockResponse() + return _f + + +class TestCreateResource: + def test_top_level_post_url_and_body(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.post", + _capture(captured), + ) + helper = _make_helper(username="u", password="p", user_auth=True) + helper.create_resource(APIResourceTypes.SYSTEM, '{"name": "x"}') + assert captured["url"] == "http://localhost:8282/sensorhub/api/systems" + assert captured["data"] == '{"name": "x"}' + assert captured["auth"] == ("u", "p") + + def test_subresource_post_threads_parent_id(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.post", + _capture(captured), + ) + helper = _make_helper() + helper.create_resource( + APIResourceTypes.DATASTREAM, '{"name": "x"}', + parent_res_id="sys-1", + ) + assert ( + captured["url"] + == "http://localhost:8282/sensorhub/api/systems/sys-1/datastreams" + ) + + def test_url_endpoint_override(self, monkeypatch): + """When url_endpoint is supplied, the URL is built off the full + API root (protocol + port + server_root + api_root) — not just + ``server_url/api_root`` (which would drop the scheme).""" + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.post", + _capture(captured), + ) + helper = _make_helper() + helper.create_resource( + APIResourceTypes.SYSTEM, '{}', url_endpoint="custom/path", + ) + assert ( + captured["url"] + == "http://localhost:8282/sensorhub/api/custom/path" + ) + + +class TestRetrieveResource: + def test_retrieve_with_id(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.get", + _capture(captured), + ) + helper = _make_helper() + helper.retrieve_resource(APIResourceTypes.SYSTEM, res_id="sys-1") + assert ( + captured["url"] + == "http://localhost:8282/sensorhub/api/systems/sys-1" + ) + + def test_retrieve_collection_when_id_omitted(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.get", + _capture(captured), + ) + helper = _make_helper() + helper.retrieve_resource(APIResourceTypes.SYSTEM) + assert captured["url"].endswith("/systems") + + +class TestGetResource: + def test_resource_type_only(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.get", + _capture(captured), + ) + helper = _make_helper() + helper.get_resource(APIResourceTypes.SYSTEM) + assert captured["url"].endswith("/systems") + + def test_resource_with_id_and_subresource(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.get", + _capture(captured), + ) + helper = _make_helper() + helper.get_resource( + APIResourceTypes.DATASTREAM, + resource_id="ds-1", + subresource_type=APIResourceTypes.SCHEMA, + ) + assert captured["url"].endswith("/datastreams/ds-1/schema") + + def test_get_resource_threads_query_params(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.get", + _capture(captured), + ) + helper = _make_helper() + helper.get_resource( + APIResourceTypes.CONTROL_CHANNEL, + resource_id="cs-1", + subresource_type=APIResourceTypes.SCHEMA, + params={"f": "json"}, + ) + assert captured["params"] == {"f": "json"} + + +class TestUpdateResource: + """Regression lock-in: the URL must include ``res_id`` (was None pre-fix).""" + + def test_top_level_put_includes_res_id(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.put", + _capture(captured), + ) + helper = _make_helper() + helper.update_resource( + APIResourceTypes.SYSTEM, "sys-1", '{"name": "renamed"}', + ) + assert ( + captured["url"] + == "http://localhost:8282/sensorhub/api/systems/sys-1" + ), "PUT URL must include the resource id; pre-fix it was /systems" + assert captured["data"] == '{"name": "renamed"}' + + def test_subresource_put_includes_both_ids(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.put", + _capture(captured), + ) + helper = _make_helper() + helper.update_resource( + APIResourceTypes.DATASTREAM, "ds-1", "{}", + parent_res_id="sys-1", + ) + assert captured["url"].endswith("/systems/sys-1/datastreams/ds-1") + + +class TestDeleteResource: + """Regression lock-in: the URL must include ``res_id`` (was None pre-fix).""" + + def test_top_level_delete_includes_res_id(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.delete", + _capture(captured), + ) + helper = _make_helper() + helper.delete_resource(APIResourceTypes.SYSTEM, "sys-1") + assert ( + captured["url"] + == "http://localhost:8282/sensorhub/api/systems/sys-1" + ), "DELETE URL must include the resource id; pre-fix it was /systems" + + def test_subresource_delete_includes_both_ids(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.delete", + _capture(captured), + ) + helper = _make_helper() + helper.delete_resource( + APIResourceTypes.DATASTREAM, "ds-1", parent_res_id="sys-1", + ) + assert captured["url"].endswith("/systems/sys-1/datastreams/ds-1") + + def test_delete_threads_auth_when_user_auth_enabled(self, monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.delete", + _capture(captured), + ) + helper = _make_helper(username="admin", password="s3cret", user_auth=True) + helper.delete_resource(APIResourceTypes.SYSTEM, "sys-1") + assert captured["auth"] == ("admin", "s3cret") diff --git a/tests/test_discovery.py b/tests/test_discovery.py new file mode 100644 index 0000000..3edbdf3 --- /dev/null +++ b/tests/test_discovery.py @@ -0,0 +1,372 @@ +"""Discovery-path tests. + +Two cohorts: + +1. ``DatastreamResource``-only: round-trip the listing JSON shape we + actually get from OSH and assert the model captures the fields the + listing returns (incl. the previously-broken ``phenomenonTime`` + alias). +2. ``System.discover_datastreams`` end-to-end: monkeypatch the listing + endpoint and the per-datastream ``/schema`` endpoint, then assert + the eager-fetch contract — every discovered ``Datastream`` carries + its SWE+JSON schema on ``_underlying_resource.record_schema``, and + a single failing schema fetch downgrades to a warning instead of + poisoning the whole call. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from oshconnect import Node, System +from oshconnect.resource_datamodels import DatastreamResource +from oshconnect.schema_datamodels import SWEDatastreamRecordSchema +from oshconnect.streamableresource import SchemaFetchWarning +from oshconnect.timemanagement import TimePeriod + +FIXTURES_DIR = Path(__file__).parent / "fixtures" + + +# --------------------------------------------------------------------------- +# DatastreamResource model fixes +# --------------------------------------------------------------------------- + +def test_datastream_resource_phenomenon_time_alias(): + """The CS API listing returns ``phenomenonTime`` (not + ``phenomenonTimeInterval``). Pre-fix, the alias mismatch left + ``phenomenon_time`` silently None on every discovered datastream.""" + raw = { + "id": "ds-x", + "name": "weather", + "validTime": ["2026-01-01T00:00:00Z", "2099-01-01T00:00:00Z"], + "phenomenonTime": ["2026-04-01T00:00:00Z", "2026-04-05T00:00:00Z"], + } + ds = DatastreamResource.model_validate(raw, by_alias=True) + assert ds.phenomenon_time is not None + assert isinstance(ds.phenomenon_time, TimePeriod) + + +def test_datastream_resource_captures_listing_fields(): + """``formats``, ``observedProperties``, and ``system@id`` are present + in the listing response — discovery should preserve them on the + parsed resource so callers can branch on supported formats etc.""" + raw = { + "id": "038s1ic7k460", + "name": "Weather - weather", + "outputName": "weather", + "system@id": "03ie1mkrr9r0", + "validTime": ["2026-01-01T00:00:00Z", "2099-01-01T00:00:00Z"], + "formats": ["application/om+json", "application/swe+json", + "application/swe+csv"], + "observedProperties": [ + {"definition": "http://mmisw.org/ont/cf/parameter/air_temperature", + "label": "Air Temperature"}, + ], + } + ds = DatastreamResource.model_validate(raw, by_alias=True) + assert ds.formats == ["application/om+json", "application/swe+json", + "application/swe+csv"] + assert ds.system_id == "03ie1mkrr9r0" + assert len(ds.observed_properties) == 1 + assert ds.observed_properties[0]["label"] == "Air Temperature" + + +# --------------------------------------------------------------------------- +# Eager schema fetch in System.discover_datastreams +# --------------------------------------------------------------------------- + +@pytest.fixture +def node() -> Node: + return Node(protocol="http", address="localhost", port=8282) + + +def _listing_payload(*ds_ids: str) -> dict: + """Listing-endpoint response shape (only the keys discovery actually + parses).""" + return { + "items": [ + { + "id": ds_id, + "name": f"weather-{ds_id}", + "outputName": "weather", + "system@id": "sys-1", + "validTime": ["2026-01-01T00:00:00Z", "2099-01-01T00:00:00Z"], + "phenomenonTime": ["2026-04-01T00:00:00Z", + "2026-04-05T00:00:00Z"], + "formats": ["application/swe+json"], + "observedProperties": [], + } + for ds_id in ds_ids + ] + } + + +class _MockResponse: + def __init__(self, payload: dict, status: int = 200): + self._payload = payload + self.status_code = status + self.ok = 200 <= status < 300 + self.headers = {} + self.text = json.dumps(payload) + + def raise_for_status(self): + if not self.ok: + from requests import HTTPError + raise HTTPError(f"{self.status_code} for url") + + def json(self): + return self._payload + + +def _install_dispatching_get(monkeypatch, listing_payload, schema_handler): + """Patch ``requests.get`` at the single point both discovery calls + funnel through (``oshconnect.csapi4py.request_wrappers.requests.get``). + Both the system-scoped listing and the per-datastream schema fetch + now go through ``APIHelper.get_resource`` → ``make_request``. + + ``schema_handler(ds_id) -> _MockResponse`` is invoked per-datastream + so a single test can vary failure modes per ds_id. + """ + def mock_get(url, params=None, headers=None, auth=None, **kwargs): + url_str = str(url) + if "/datastreams/" in url_str and url_str.endswith("/schema"): + ds_id = url_str.rsplit("/", 2)[-2] + return schema_handler(ds_id) + # Fallback: the system-scoped listing + return _MockResponse(listing_payload) + + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.get", mock_get, + ) + + +def test_discover_datastreams_populates_record_schema(node, monkeypatch): + """After discovery, every Datastream's underlying resource carries + its SWE+JSON schema. Without this, callers downstream would get + ``record_schema=None`` and silently fail.""" + swe_schema = json.loads( + (FIXTURES_DIR / "fake_weather_schema_swejson.json").read_text() + ) + + _install_dispatching_get( + monkeypatch, + listing_payload=_listing_payload("ds-1"), + schema_handler=lambda ds_id: _MockResponse(swe_schema), + ) + + sys = System(label="S", urn="urn:test:s", + parent_node=node, resource_id="sys-1") + discovered = sys.discover_datastreams() + + assert len(discovered) == 1 + populated = discovered[0]._underlying_resource.record_schema + assert isinstance(populated, SWEDatastreamRecordSchema) + assert populated.obs_format == "application/swe+json" + assert populated.record_schema.name == "weather" + assert {f.name for f in populated.record_schema.fields} == { + "time", "temperature", "pressure", "windSpeed", "windDirection", + } + + +def test_discover_datastreams_continues_on_schema_fetch_failure(node, monkeypatch): + """A single failing /schema call must not poison the entire discovery + run. The failing datastream gets ``record_schema=None`` plus a + warning; subsequent datastreams' schemas still populate.""" + swe_schema = json.loads( + (FIXTURES_DIR / "fake_weather_schema_swejson.json").read_text() + ) + + def schema_handler(ds_id): + if ds_id == "ds-broken": + return _MockResponse({"error": "boom"}, status=500) + return _MockResponse(swe_schema) + + _install_dispatching_get( + monkeypatch, + listing_payload=_listing_payload("ds-broken", "ds-ok"), + schema_handler=schema_handler, + ) + + sys = System(label="S", urn="urn:test:s", + parent_node=node, resource_id="sys-1") + + with pytest.warns(SchemaFetchWarning, + match=r"Failed to fetch application/swe\+json schema"): + discovered = sys.discover_datastreams() + + assert len(discovered) == 2 + by_id = {d._underlying_resource.ds_id: d for d in discovered} + assert by_id["ds-broken"]._underlying_resource.record_schema is None + assert isinstance( + by_id["ds-ok"]._underlying_resource.record_schema, + SWEDatastreamRecordSchema, + ) + + +def test_discover_datastreams_logs_traceback_on_schema_failure(node, monkeypatch, caplog): + """A schema-fetch failure must surface in the root logger with the + full traceback (`exc_info=True`), so users who configure logging + (the common case) actually see *what* broke — not just that + something did.""" + swe_schema = json.loads( + (FIXTURES_DIR / "fake_weather_schema_swejson.json").read_text() + ) + + def schema_handler(ds_id): + if ds_id == "ds-broken": + return _MockResponse({"error": "boom"}, status=500) + return _MockResponse(swe_schema) + + _install_dispatching_get( + monkeypatch, + listing_payload=_listing_payload("ds-broken", "ds-ok"), + schema_handler=schema_handler, + ) + + sys = System(label="S", urn="urn:test:s", + parent_node=node, resource_id="sys-1") + + import logging as _logging + with caplog.at_level(_logging.ERROR): + with pytest.warns(SchemaFetchWarning): + sys.discover_datastreams() + + error_records = [r for r in caplog.records if r.levelno == _logging.ERROR] + assert any("ds-broken" in r.getMessage() for r in error_records), ( + "expected an ERROR log mentioning the failing datastream id" + ) + # exc_info plumbed through: the record carries the original exception + assert any(r.exc_info is not None for r in error_records), ( + "expected at least one ERROR record to carry exc_info (traceback)" + ) + + +# --------------------------------------------------------------------------- +# Node.discover_systems: parsed SystemResource must be bound to the wrapper +# --------------------------------------------------------------------------- + +def test_discover_systems_pins_sml_json_format(node, monkeypatch): + """``Node.discover_systems`` must request the SML+JSON listing + explicitly via ``?f=application/sml+json``. Without the pin, OSH + returns a summary GeoJSON listing that drops the SensorML detail + (``identifiers``, ``characteristics``, ``definition``, etc.) that + cross-node sync needs.""" + captured: dict = {} + + def mock_get(url, params=None, headers=None, auth=None, **kwargs): + captured["url"] = str(url) + captured["params"] = params + return _MockResponse({"items": []}) + + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.get", mock_get, + ) + node.discover_systems() + assert captured["url"].endswith("/systems"), captured["url"] + assert captured["params"] == {"f": "application/sml+json"}, captured["params"] + + +def test_discover_systems_binds_full_underlying_resource_from_sml(node, monkeypatch): + """Regression on two intertwined bugs: + + (a) ``Node.discover_systems`` previously constructed the wrapper via + the bare ``System(label=..., urn=..., resource_id=...)`` + constructor, which never called ``set_system_resource(...)``. The + parsed resource was dropped — any caller reaching for + ``_underlying_resource`` (cross-node sync, geometry, validTime, + SensorML metadata) saw a thin ``PhysicalSystem`` shell. + + (b) The format was not pinned, so OSH returned a GeoJSON summary + listing missing every SensorML field. + + The fix: route through ``System.from_resource(...)`` (which binds the + resource) and pin ``?f=application/sml+json`` (which delivers the + rich body). This test mirrors the SML+JSON wire shape that + ``localhost:8282`` returns when the format is pinned.""" + listing = { + "items": [ + { + "type": "PhysicalSystem", + "id": "sys-from-discovery", + "uniqueId": "urn:test:rich:001", + "definition": "http://www.w3.org/ns/sosa/Sensor", + "label": "Rich Test Sensor", + "description": "A sensor with all the trimmings", + "identifiers": [ + {"definition": "http://sensorml.com/ont/swe/property/SerialNumber", + "label": "Serial Number", "value": "0123456879"}, + ], + "validTime": ["2026-04-05T03:54:09.165Z", "now"], + } + ] + } + + def mock_get(url, params=None, headers=None, auth=None, **kwargs): + return _MockResponse(listing) + + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.get", mock_get, + ) + + discovered = node.discover_systems() + assert discovered is not None + assert len(discovered) == 1 + sys_obj = discovered[0] + + # The wrapper must hold the full parsed resource — not a shell. + underlying = sys_obj._underlying_resource + assert underlying is not None, ( + "discover_systems must bind the parsed SystemResource via " + "set_system_resource(...). Bare constructor drops it on the floor." + ) + # SML+JSON fields land directly on the resource (no `properties` indirection). + assert underlying.system_id == "sys-from-discovery" + assert underlying.uid == "urn:test:rich:001" + assert underlying.label == "Rich Test Sensor" + assert underlying.description == "A sensor with all the trimmings" + assert underlying.feature_type == "PhysicalSystem" + # SensorML detail that the GeoJSON listing would drop. + assert underlying.definition == "http://www.w3.org/ns/sosa/Sensor" + assert underlying.identifiers and len(underlying.identifiers) == 1 + # Wrapper display fields use the raw human-readable label. + assert sys_obj.label == "Rich Test Sensor" + + +def test_discover_systems_still_handles_geojson_fallback(node, monkeypatch): + """If a non-OSH server (or a future OSH variant) returns GeoJSON + despite the SML+JSON format pin, ``SystemResource.model_validate`` + still parses it and the factory's GeoJSON branch + (``_construct_from_resource`` line 1067) routes name/uid through + ``properties``. We don't want a server-side format ignore to break + discovery silently.""" + listing = { + "items": [ + { + "type": "Feature", + "id": "sys-geojson", + "geometry": {"type": "Point", "coordinates": [-86.7, 34.8, 0]}, + "properties": { + "uid": "urn:test:geo:1", + "name": "Fallback GeoJSON Sensor", + "featureType": "http://www.w3.org/ns/sosa/Sensor", + }, + } + ] + } + + def mock_get(url, params=None, headers=None, auth=None, **kwargs): + return _MockResponse(listing) + + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.get", mock_get, + ) + + discovered = node.discover_systems() + assert len(discovered) == 1 + sys_obj = discovered[0] + assert sys_obj._underlying_resource is not None + assert sys_obj._underlying_resource.system_id == "sys-geojson" + assert sys_obj.label == "Fallback GeoJSON Sensor" + assert sys_obj.urn == "urn:test:geo:1" \ No newline at end of file diff --git a/tests/test_imports.py b/tests/test_imports.py index 4e25a6e..9f7bbae 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -1,147 +1,55 @@ -# ============================================================================= -# Copyright (c) 2025 Botts Innovative Research Inc. -# Date: 2025/4/2 -# Author: Ian Patterson -# Contact Email: ian@botts-inc.com -# ============================================================================= -# -# Verifies that all public symbols are importable from the top-level package -# and from the csapi4py subpackage. Run with: -# uv run pytest tests/test_imports.py -# -# Requirements: the package must be installed in the environment first: -# uv sync (or) pip install -e . -# ============================================================================= - - -# --------------------------------------------------------------------------- -# Top-level package -# --------------------------------------------------------------------------- - -def test_core_resources_importable(): - from oshconnect import OSHConnect, Node, System, Datastream, ControlStream - assert OSHConnect is not None - assert Node is not None - assert System is not None - assert Datastream is not None - assert ControlStream is not None - - -def test_streaming_enums_importable(): - from oshconnect import StreamableModes, Status - assert StreamableModes is not None - assert Status is not None - - -def test_time_management_importable(): - from oshconnect import TimePeriod, TimeInstant, TemporalModes, TimeUtils - assert TimePeriod is not None - assert TimeInstant is not None - assert TemporalModes is not None - assert TimeUtils is not None - - -def test_resource_datamodels_importable(): - from oshconnect import ( - SystemResource, - DatastreamResource, - ControlStreamResource, - ObservationResource, - ) - assert SystemResource is not None - assert DatastreamResource is not None - assert ControlStreamResource is not None - assert ObservationResource is not None - - -def test_swe_schema_components_importable(): - from oshconnect import ( - DataRecordSchema, - VectorSchema, - QuantitySchema, - TimeSchema, - BooleanSchema, - CountSchema, - CategorySchema, - TextSchema, - QuantityRangeSchema, - TimeRangeSchema, - ) - for cls in (DataRecordSchema, VectorSchema, QuantitySchema, TimeSchema, - BooleanSchema, CountSchema, CategorySchema, TextSchema, - QuantityRangeSchema, TimeRangeSchema): - assert cls is not None - - -def test_schema_datamodels_importable(): - from oshconnect import SWEDatastreamRecordSchema, JSONCommandSchema - assert SWEDatastreamRecordSchema is not None - assert JSONCommandSchema is not None - - -def test_event_system_importable(): - from oshconnect import ( - EventHandler, - IEventListener, - DefaultEventTypes, - AtomicEventTypes, - Event, - EventBuilder, - ) - assert EventHandler is not None - assert IEventListener is not None - assert DefaultEventTypes is not None - assert AtomicEventTypes is not None - assert Event is not None - assert EventBuilder is not None - - -def test_csapi_constants_importable(): - from oshconnect import ObservationFormat, APIResourceTypes, ContentTypes - assert ObservationFormat is not None - assert APIResourceTypes is not None - assert ContentTypes is not None - - -def test_all_list_present_and_complete(): - import oshconnect - assert hasattr(oshconnect, "__all__") - assert len(oshconnect.__all__) > 0 - for name in oshconnect.__all__: - assert hasattr(oshconnect, name), f"__all__ lists '{name}' but it is not importable" - - -# --------------------------------------------------------------------------- -# csapi4py subpackage -# --------------------------------------------------------------------------- - -def test_csapi4py_constants_importable(): - from oshconnect.csapi4py import APIResourceTypes, ObservationFormat, ContentTypes, APITerms, SystemTypes - assert APIResourceTypes is not None - assert ObservationFormat is not None - assert ContentTypes is not None - assert APITerms is not None - assert SystemTypes is not None - - -def test_csapi4py_request_builder_importable(): - from oshconnect.csapi4py import ConnectedSystemsRequestBuilder, ConnectedSystemAPIRequest - assert ConnectedSystemsRequestBuilder is not None - assert ConnectedSystemAPIRequest is not None - - -def test_csapi4py_mqtt_importable(): - from oshconnect.csapi4py import MQTTCommClient - assert MQTTCommClient is not None - - -def test_csapi4py_api_helper_importable(): - from oshconnect.csapi4py import APIHelper - assert APIHelper is not None - - -def test_csapi4py_all_list_present_and_complete(): - import oshconnect.csapi4py as csapi4py - assert hasattr(csapi4py, "__all__") - for name in csapi4py.__all__: - assert hasattr(csapi4py, name), f"__all__ lists '{name}' but it is not importable" \ No newline at end of file +"""Public-API smoke tests: every name in `oshconnect.__all__` and +`oshconnect.csapi4py.__all__` is importable from its package, and the +re-exports we document users relying on actually resolve. +""" +import importlib + +import pytest + +# (package, names) — the documented public surface, grouped by concern. +EXPECTED_REEXPORTS = [ + ("oshconnect", ["OSHConnect", "Node", "System", "Datastream", "ControlStream"]), + ("oshconnect", ["StreamableModes", "Status"]), + ("oshconnect", ["TimePeriod", "TimeInstant", "TemporalModes", "TimeUtils"]), + ("oshconnect", ["SystemResource", "DatastreamResource", "ControlStreamResource", + "ObservationResource"]), + ("oshconnect", ["DataRecordSchema", "VectorSchema", "QuantitySchema", + "TimeSchema", "BooleanSchema", "CountSchema", "CategorySchema", + "TextSchema", "QuantityRangeSchema", "TimeRangeSchema"]), + ("oshconnect", ["SWEDatastreamRecordSchema", "JSONCommandSchema"]), + ("oshconnect", ["EventHandler", "IEventListener", "DefaultEventTypes", + "AtomicEventTypes", "Event", "EventBuilder"]), + ("oshconnect", ["ObservationFormat", "APIResourceTypes", "ContentTypes"]), + ("oshconnect.csapi4py", ["APIResourceTypes", "ObservationFormat", "ContentTypes", + "APITerms", "SystemTypes"]), + ("oshconnect.csapi4py", ["ConnectedSystemsRequestBuilder", + "ConnectedSystemAPIRequest"]), + ("oshconnect.csapi4py", ["MQTTCommClient"]), + ("oshconnect.csapi4py", ["APIHelper"]), +] + + +@pytest.mark.parametrize( + "package,names", + EXPECTED_REEXPORTS, + ids=[f"{pkg}:{','.join(names[:2])}{'…' if len(names) > 2 else ''}" + for pkg, names in EXPECTED_REEXPORTS], +) +def test_documented_reexports_resolve(package, names): + mod = importlib.import_module(package) + for name in names: + assert hasattr(mod, name), ( + f"{package} is expected to re-export {name!r} but does not" + ) + assert getattr(mod, name) is not None + + +@pytest.mark.parametrize("package", ["oshconnect", "oshconnect.csapi4py"]) +def test_all_list_present_and_complete(package): + mod = importlib.import_module(package) + assert hasattr(mod, "__all__"), f"{package} has no __all__" + assert len(mod.__all__) > 0, f"{package}.__all__ is empty" + for name in mod.__all__: + assert hasattr(mod, name), ( + f"{package}.__all__ lists {name!r} but it is not importable" + ) \ No newline at end of file diff --git a/tests/test_mqtt_topics.py b/tests/test_mqtt_topics.py index e22d874..88bc145 100644 --- a/tests/test_mqtt_topics.py +++ b/tests/test_mqtt_topics.py @@ -63,7 +63,7 @@ def make_controlstream(node=None): def make_system(node=None): if node is None: node = make_mock_node() - sys = System(name="test_system", label="Test System", urn="urn:test:system", parent_node=node, + sys = System(label="Test System", urn="urn:test:system", parent_node=node, resource_id=SYS_ID) return sys @@ -108,9 +108,11 @@ def test_status_data_topic(self): assert topic == f"api/controlstreams/{CS_ID}/status:data" def test_status_topic_set_on_init(self): - """_status_topic is assigned in __init__ before any explicit init_mqtt call.""" + """_status_topic is assigned in __init__ before any explicit + init_mqtt call. Status payloads are always JSON, so the topic + carries the ``/json`` format subtopic.""" cs = make_controlstream() - assert cs._status_topic == f"api/controlstreams/{CS_ID}/status:data" + assert cs._status_topic == f"api/controlstreams/{CS_ID}/status:data/json" def test_init_mqtt_sets_command_topic(self): node = make_mock_node() @@ -156,9 +158,61 @@ def test_publish_routes_status_to_status_topic(self): cs.publish("payload", topic=APIResourceTypes.STATUS.value) mock_mqtt.publish.assert_called_once_with( - f"api/controlstreams/{CS_ID}/status:data", "payload", qos=0 + f"api/controlstreams/{CS_ID}/status:data/json", "payload", qos=0 ) + def test_publish_default_topic_routes_to_command_topic(self): + """Regression: prior to the topic-default fix, calling + ``cs.publish(payload)`` with no topic argument used the lowercase + default ``'command'`` which never matched + ``APIResourceTypes.COMMAND.value`` (``'Command'``) and raised + ``ValueError`` instead of publishing. The default must canonicalize + on the enum value.""" + node = make_mock_node() + mock_mqtt = MagicMock() + node.get_mqtt_client.return_value = mock_mqtt + + cs = make_controlstream(node) + cs.init_mqtt() + cs.publish("payload") # no topic argument — must hit the command path + + mock_mqtt.publish.assert_called_once_with( + f"api/controlstreams/{CS_ID}/commands:data", "payload", qos=0 + ) + + def test_publish_unknown_topic_error_names_canonical_values(self): + node = make_mock_node() + node.get_mqtt_client.return_value = MagicMock() + cs = make_controlstream(node) + cs.init_mqtt() + with pytest.raises(ValueError) as excinfo: + cs.publish("payload", topic="command") # lowercase — invalid + msg = str(excinfo.value) + assert "'Command'" in msg and "'Status'" in msg + + def test_subscribe_default_topic_routes_to_command_topic(self): + node = make_mock_node() + mock_mqtt = MagicMock() + node.get_mqtt_client.return_value = mock_mqtt + + cs = make_controlstream(node) + cs.init_mqtt() + cs.subscribe() # topic=None default + + mock_mqtt.subscribe.assert_called_once() + args, kwargs = mock_mqtt.subscribe.call_args + assert args[0] == f"api/controlstreams/{CS_ID}/commands:data" + + def test_subscribe_unknown_topic_error_names_canonical_values(self): + node = make_mock_node() + node.get_mqtt_client.return_value = MagicMock() + cs = make_controlstream(node) + cs.init_mqtt() + with pytest.raises(ValueError) as excinfo: + cs.subscribe(topic="command") # lowercase — invalid + msg = str(excinfo.value) + assert "'Command'" in msg and "'Status'" in msg and "None" in msg + class TestSystemTopics: def test_system_data_topic(self): @@ -258,3 +312,159 @@ def test_http_api_root_unaffected(self): node = self.make_node() assert node.get_api_helper().api_root == self.HTTP_ROOT assert node.get_api_helper().get_mqtt_root() == self.MQTT_ROOT + + +class TestDataTopicFormatSubtopic: + """CS API Part 3 §Resource Data Messages Content Negotiation — the + optional ``:data/`` subtopic selects the wire format. Mirrors + the Java reference ``ConSysTopicValidator.FORMAT_SUBTOPICS``.""" + + @pytest.mark.parametrize("content_type,token", [ + ("application/json", "json"), + ("application/swe+json", "swe-json"), + ("application/swe+binary", "swe-binary"), + ("application/swe+csv", "swe-csv"), + ("application/om+json", "om-json"), + ("application/sml+json", "sml-json"), + ]) + def test_format_token_mapping(self, content_type, token): + from src.oshconnect.csapi4py.mqtt import mqtt_topic_format_token + assert mqtt_topic_format_token(content_type) == token + + def test_unknown_format_raises_value_error(self): + from src.oshconnect.csapi4py.mqtt import mqtt_topic_format_token + with pytest.raises(ValueError, match="No MQTT topic-format token"): + mqtt_topic_format_token("application/swe+protobuf") + + def test_get_mqtt_topic_omits_format_when_none(self): + """``format=None`` (default) emits bare ``:data`` so the server's + default format applies. Preserves prior behavior for any callers + that don't know the wire format.""" + helper = make_mock_node().get_api_helper() + topic = helper.get_mqtt_topic( + resource_type=APIResourceTypes.DATASTREAM, + subresource_type=APIResourceTypes.OBSERVATION, + resource_id=DS_ID, + data_topic=True, + ) + assert topic == f"api/datastreams/{DS_ID}/observations:data" + + def test_get_mqtt_topic_appends_format_when_provided(self): + helper = make_mock_node().get_api_helper() + topic = helper.get_mqtt_topic( + resource_type=APIResourceTypes.DATASTREAM, + subresource_type=APIResourceTypes.OBSERVATION, + resource_id=DS_ID, + data_topic=True, + format="application/swe+binary", + ) + assert topic == f"api/datastreams/{DS_ID}/observations:data/swe-binary" + + def test_get_mqtt_topic_raises_for_unknown_format(self): + helper = make_mock_node().get_api_helper() + with pytest.raises(ValueError, match="No MQTT topic-format token"): + helper.get_mqtt_topic( + resource_type=APIResourceTypes.DATASTREAM, + subresource_type=APIResourceTypes.OBSERVATION, + resource_id=DS_ID, + data_topic=True, + format="application/swe+protobuf", + ) + + def test_get_mqtt_topic_ignores_format_on_event_topic(self): + """Event topics (no ``:data`` suffix) never carry a format + subtopic — the format param is silently ignored.""" + helper = make_mock_node().get_api_helper() + topic = helper.get_mqtt_topic( + resource_type=APIResourceTypes.SYSTEM, + subresource_type=APIResourceTypes.DATASTREAM, + resource_id=SYS_ID, + data_topic=False, + format="application/swe+binary", + ) + assert topic == f"api/systems/{SYS_ID}/datastreams" + + def test_datastream_init_mqtt_with_swe_binary_schema_appends_token(self): + """When a Datastream carries a swe+binary record_schema, + init_mqtt() builds a topic with the matching format subtopic.""" + from src.oshconnect.schema_datamodels import SWEBinaryDatastreamRecordSchema + node = make_mock_node() + node.get_mqtt_client.return_value = MagicMock() + ds = make_datastream(node) + ds._underlying_resource.record_schema = ( + SWEBinaryDatastreamRecordSchema.model_construct( + obs_format="application/swe+binary", + ) + ) + ds.init_mqtt() + assert ds._topic == f"api/datastreams/{DS_ID}/observations:data/swe-binary" + + def test_datastream_init_mqtt_with_swe_json_schema_appends_token(self): + from src.oshconnect.schema_datamodels import SWEDatastreamRecordSchema + node = make_mock_node() + node.get_mqtt_client.return_value = MagicMock() + ds = make_datastream(node) + ds._underlying_resource.record_schema = ( + SWEDatastreamRecordSchema.model_construct( + obs_format="application/swe+json", + ) + ) + ds.init_mqtt() + assert ds._topic == f"api/datastreams/{DS_ID}/observations:data/swe-json" + + def test_datastream_init_mqtt_without_schema_stays_bare(self): + """No record_schema → no known format → bare ``:data`` topic so + the server's default applies.""" + node = make_mock_node() + node.get_mqtt_client.return_value = MagicMock() + ds = make_datastream(node) + assert ds._underlying_resource.record_schema is None + ds.init_mqtt() + assert ds._topic == f"api/datastreams/{DS_ID}/observations:data" + + def test_controlstream_init_mqtt_with_swe_json_schema_appends_token(self): + from src.oshconnect.schema_datamodels import SWEJSONCommandSchema + node = make_mock_node() + node.get_mqtt_client.return_value = MagicMock() + cs = make_controlstream(node) + cs._underlying_resource.command_schema = ( + SWEJSONCommandSchema.model_construct( + command_format="application/swe+json", + ) + ) + cs.init_mqtt() + assert cs._topic == f"api/controlstreams/{CS_ID}/commands:data/swe-json" + + def test_controlstream_init_mqtt_with_json_command_schema_appends_token(self): + from src.oshconnect.schema_datamodels import JSONCommandSchema + node = make_mock_node() + node.get_mqtt_client.return_value = MagicMock() + cs = make_controlstream(node) + cs._underlying_resource.command_schema = ( + JSONCommandSchema.model_construct( + command_format="application/json", + ) + ) + cs.init_mqtt() + assert cs._topic == f"api/controlstreams/{CS_ID}/commands:data/json" + + def test_controlstream_status_topic_always_uses_json_token(self): + """Status payloads are always JSON regardless of the command + format, so the status topic is always suffixed with ``/json``.""" + cs = make_controlstream() + assert cs._status_topic == f"api/controlstreams/{CS_ID}/status:data/json" + + def test_custom_mqtt_topic_root_preserved_with_format(self): + """Format subtopic stacks correctly when a custom mqtt_topic_root + is in play — the suffix is appended after ``:data``, not after + the topic root.""" + node = make_mock_node(api_root="api", mqtt_topic_root="osh/mqtt") + helper = node.get_api_helper() + topic = helper.get_mqtt_topic( + resource_type=APIResourceTypes.DATASTREAM, + subresource_type=APIResourceTypes.OBSERVATION, + resource_id=DS_ID, + data_topic=True, + format="application/swe+binary", + ) + assert topic == f"osh/mqtt/datastreams/{DS_ID}/observations:data/swe-binary" diff --git a/tests/test_node.py b/tests/test_node.py new file mode 100644 index 0000000..104f352 --- /dev/null +++ b/tests/test_node.py @@ -0,0 +1,24 @@ +"""Node and APIHelper basics: URL construction and (de)serialization.""" +from oshconnect import Node +from oshconnect.csapi4py import APIHelper + + +def test_apihelper_url_generation(): + helper = APIHelper(server_url='localhost', port=8282, protocol='http', + username='admin', password='admin') + + assert helper.get_api_root_url() == "http://localhost:8282/sensorhub/api" + assert helper.get_api_root_url(socket=True) == "ws://localhost:8282/sensorhub/api" + + helper.set_protocol('https') + assert helper.get_api_root_url() == "https://localhost:8282/sensorhub/api" + assert helper.get_api_root_url(socket=True) == "wss://localhost:8282/sensorhub/api" + + +def test_node_password_round_trips_through_storage_dict(): + node = Node(protocol='http', address='localhost', port=8080, + username='user', password='pass') + stored = node.to_storage_dict() + assert stored['password'] == 'pass' + rehydrated = Node.from_storage_dict(stored) + assert rehydrated._api_helper.password == 'pass' \ No newline at end of file diff --git a/tests/test_node_to_node_sync.py b/tests/test_node_to_node_sync.py new file mode 100644 index 0000000..44a8092 --- /dev/null +++ b/tests/test_node_to_node_sync.py @@ -0,0 +1,440 @@ +"""Cross-node sync integration tests. + +Each test fetches a datastream's SWE+JSON schema from a source OSH node and +uses it to create a fresh datastream on a destination node, verifying the +end-to-end conversion path. Both servers must be running locally; the +tests are tagged ``@pytest.mark.network`` and skipped by default in CI +(see ``.github/workflows/tests.yaml``). + +Default endpoints: + SRC_PORT = 8282 (provides datastreams to fetch from) + DEST_PORT = 8382 (receives newly-created datastreams) + +Override per-run with ``OSHC_SRC_PORT`` / ``OSHC_DEST_PORT`` env vars. +""" +from __future__ import annotations + +import os +import uuid + +import pytest +import requests + +from oshconnect import Node, System +from oshconnect.csapi4py.constants import APIResourceTypes +from oshconnect.encoding import JSONEncoding +from oshconnect.resource_datamodels import ControlStreamResource, DatastreamResource +from oshconnect.schema_datamodels import ( + CommandJSON, + SWEDatastreamRecordSchema, + SWEJSONCommandSchema, +) +from oshconnect.timemanagement import TimeInstant, TimePeriod, TimeUtils + +SRC_PORT = int(os.environ.get("OSHC_SRC_PORT", "8282")) +DEST_PORT = int(os.environ.get("OSHC_DEST_PORT", "8382")) +NODE_TIMEOUT = 2.0 + + +def _node_reachable(port: int) -> bool: + """True if HTTP root responds with anything in [200, 400).""" + try: + r = requests.get( + f"http://localhost:{port}/sensorhub/api/", + timeout=NODE_TIMEOUT, + auth=("admin", "admin"), + ) + return 200 <= r.status_code < 400 + except (requests.RequestException, OSError): + return False + + +def _make_node(port: int) -> Node: + return Node( + protocol="http", address="localhost", port=port, + username="admin", password="admin", + ) + + +@pytest.fixture +def src_node(): + if not _node_reachable(SRC_PORT): + pytest.skip(f"src OSH node not reachable at localhost:{SRC_PORT}") + return _make_node(SRC_PORT) + + +@pytest.fixture +def dest_node(): + if not _node_reachable(DEST_PORT): + pytest.skip(f"dest OSH node not reachable at localhost:{DEST_PORT}") + return _make_node(DEST_PORT) + + +def _first_datastream_with_schema(node: Node): + """Walk this node's systems and return the first datastream that has + something fetch-able. Returns ``None`` if no datastream exists.""" + systems = node.discover_systems() or [] + for sys in systems: + datastreams = sys.discover_datastreams() + if datastreams: + return datastreams[0] + return None + + +def _ensure_dest_system(node: Node) -> tuple[System, bool]: + """Find or create a system on the destination node to attach new + datastreams to. Returns ``(system, created_by_us)`` so cleanup can + decide whether to tear the system down.""" + systems = node.discover_systems() + if systems: + return systems[0], False + sys = System( + label="Sync Target System", + urn=f"urn:test:cross-node-sync:{uuid.uuid4().hex[:8]}", + parent_node=node, + ) + sys.insert_self() + return sys, True + + +def _delete_resource(node: Node, path: str) -> None: + """Best-effort DELETE against ``://:/sensorhub/api/``. + Suppresses errors so cleanup never masks a real test failure.""" + url = f"{node.protocol}://{node.address}:{node.port}/sensorhub/api/{path}" + try: + requests.delete(url, auth=("admin", "admin"), timeout=NODE_TIMEOUT) + except (requests.RequestException, OSError): + pass + + +@pytest.mark.network +def test_swejson_schema_round_trips_src_to_dest(src_node, dest_node): + """Pull the first datastream's SWE+JSON schema from the source node + via the eager-fetch cache populated by ``discover_datastreams``, use + its ``recordSchema`` (the inner SWE Common DataRecord) to create a + new datastream on the destination, then verify by re-discovering on + dest and comparing the cached schema.""" + src_ds = _first_datastream_with_schema(src_node) + if src_ds is None: + pytest.skip(f"no datastreams found on any system at :{SRC_PORT}") + + # Eager-fetch contract: discover_datastreams populates the SWE+JSON + # schema on the underlying resource. Without this, every workflow + # that needs the schema (cross-node sync, observation building, etc.) + # silently breaks. + cached = src_ds._underlying_resource.record_schema + assert cached is not None, ( + "discover_datastreams should populate _underlying_resource.record_schema" + ) + assert isinstance(cached, SWEDatastreamRecordSchema) + src_record = cached.record_schema + assert src_record.name, "source schema's recordSchema has no name" + + # Ensure a system on the destination to attach to. + dest_sys, created_dest_sys = _ensure_dest_system(dest_node) + dest_sys_id = dest_sys._resource_id # System has no public id getter + new_id = None + + try: + # `System.add_insert_datastream` takes a fully-built + # `DatastreamResource` (caller assembles the SWE+JSON envelope, + # output_name, validTime). We wrap the source's inner record + # schema and POST to dest's `/systems/{id}/datastreams`. + dest_resource = DatastreamResource( + ds_id="default", + name=src_record.name, + output_name=src_record.name, + record_schema=SWEDatastreamRecordSchema( + record_schema=src_record, + obs_format="application/swe+json", + ), + valid_time=TimePeriod( + start=TimeInstant.now_as_time_instant(), + end=TimeInstant( + utc_time=TimeUtils.to_utc_time("2026-12-31T00:00:00Z") + ), + ), + ) + new_ds = dest_sys.add_insert_datastream(dest_resource) + assert new_ds is not None, "add_insert_datastream returned None" + + new_id = new_ds.get_id() + assert new_id and new_id != "default", ( + f"expected a real server-assigned datastream id from dest's " + f"Location header; got {new_id!r}" + ) + + # Round-trip verify: re-discover on dest and confirm the schema + # we POSTed comes back with the same structure. + dest_streams = dest_sys.discover_datastreams() + dest_match = next((d for d in dest_streams if d.get_id() == new_id), None) + assert dest_match is not None, ( + f"newly-created datastream {new_id!r} not found in " + f"discover_datastreams() on dest" + ) + dest_cached = dest_match._underlying_resource.record_schema + assert isinstance(dest_cached, SWEDatastreamRecordSchema) + dest_record = dest_cached.record_schema + assert dest_record.name == src_record.name, ( + f"recordSchema.name didn't round-trip: " + f"src={src_record.name!r}, dest={dest_record.name!r}" + ) + + src_fields = {f.name for f in src_record.fields} + dest_fields = {f.name for f in dest_record.fields} + assert src_fields == dest_fields, ( + f"field names differ across sync: " + f"src={src_fields}, dest={dest_fields}" + ) + + print( + f"Synced datastream {src_ds.get_id()} from :{SRC_PORT} → " + f"datastream {new_id} on :{DEST_PORT} " + f"(fields: {sorted(src_fields)})" + ) + finally: + # Best-effort teardown: drop the datastream we created, then the + # system if we created it. Runs on success and failure so the + # dest node doesn't accumulate test residue across runs. + if new_id: + _delete_resource(dest_node, f"datastreams/{new_id}") + if created_dest_sys and dest_sys_id: + _delete_resource(dest_node, f"systems/{dest_sys_id}") + + +def _first_controlstream_with_schema(node: Node): + """Walk this node's systems and return the first control stream that + has a populated command schema. Returns ``None`` if none exists.""" + systems = node.discover_systems() or [] + for sys in systems: + controlstreams = sys.discover_controlstreams() + for cs in controlstreams: + if cs._underlying_resource.command_schema is not None: + return cs + return None + + +@pytest.mark.network +def test_command_schema_round_trips_src_to_dest(src_node, dest_node): + """Fetch the first control stream's command schema from the source + node, use its ``parametersSchema`` (the inner SWE Common component — + a `DataChoice` for the controllable counter) to create a new control + stream on the destination, then verify by reading the new schema + back and comparing structure. + + Mirrors `test_swejson_schema_round_trips_src_to_dest` but for + `/controlstreams`. The CS API returns command schemas as + ``application/json`` envelopes carrying a ``parametersSchema`` SWE + component; we wrap it in a fresh `JSONCommandSchema` for the dest + POST. + """ + src_cs = _first_controlstream_with_schema(src_node) + if src_cs is None: + pytest.skip(f"no control streams with schemas found on any system at :{SRC_PORT}") + + # Eager-fetch contract: discover_controlstreams should already have + # populated the command schema on the underlying resource. + cached = src_cs._underlying_resource.command_schema + assert cached is not None, ( + "discover_controlstreams should populate _underlying_resource.command_schema" + ) + assert isinstance(cached, JSONCommandSchema) + src_params = cached.params_schema + assert src_params.name, "source command schema's parametersSchema has no name" + + # Ensure a system on the destination to attach to. + dest_sys, created_dest_sys = _ensure_dest_system(dest_node) + dest_sys_id = dest_sys._resource_id + new_id = None + + try: + # Wrap the source's parametersSchema in a fresh JSONCommandSchema + # and POST to dest's `/systems/{id}/controlstreams`. + src_input_name = src_cs._underlying_resource.input_name or src_params.name + dest_resource = ControlStreamResource( + cs_id="default", + name=src_cs._underlying_resource.name, + input_name=src_input_name, + command_schema=JSONCommandSchema( + command_format="application/json", + params_schema=src_params, + ), + valid_time=TimePeriod( + start=TimeInstant.now_as_time_instant(), + end=TimeInstant( + utc_time=TimeUtils.to_utc_time("2026-12-31T00:00:00Z") + ), + ), + ) + new_cs = dest_sys.add_insert_controlstream(dest_resource) + assert new_cs is not None, "add_insert_controlstream returned None" + + new_id = new_cs.get_id() + assert new_id and new_id != "default", ( + f"expected a real server-assigned control-stream id from dest's " + f"Location header; got {new_id!r}" + ) + + # Round-trip verify: re-discover on dest and confirm the schema + # we POSTed comes back with the same structure. + dest_streams = dest_sys.discover_controlstreams() + dest_match = next((cs for cs in dest_streams if cs.get_id() == new_id), None) + assert dest_match is not None, ( + f"newly-created control stream {new_id!r} not found in " + f"discover_controlstreams() on dest" + ) + dest_cmd_schema = dest_match._underlying_resource.command_schema + assert isinstance(dest_cmd_schema, JSONCommandSchema) + dest_params = dest_cmd_schema.params_schema + assert dest_params.name == src_params.name, ( + f"parametersSchema.name didn't round-trip: " + f"src={src_params.name!r}, dest={dest_params.name!r}" + ) + + def _child_names(component): + # DataChoice has `items`, DataRecord has `fields`. Either is + # a list of named SWE components. + for attr in ("items", "fields"): + children = getattr(component, attr, None) + if children: + return {c.name for c in children} + return set() + + src_children = _child_names(src_params) + dest_children = _child_names(dest_params) + assert src_children == dest_children, ( + f"command schema child names differ across sync: " + f"src={src_children}, dest={dest_children}" + ) + + print( + f"Synced control stream {src_cs.get_id()} from :{SRC_PORT} → " + f"control stream {new_id} on :{DEST_PORT} " + f"(child fields: {sorted(src_children)})" + ) + finally: + if new_id: + _delete_resource(dest_node, f"controlstreams/{new_id}") + if created_dest_sys and dest_sys_id: + _delete_resource(dest_node, f"systems/{dest_sys_id}") + + +def _build_command_payload(cmd_schema: JSONCommandSchema) -> dict: + """Build a sensible command payload for the given parsed command + schema. Picks the first scalar item with a known type. Used to + exercise the send-command code path without hard-coding a sensor's + parameter names.""" + params = cmd_schema.params_schema + # DataChoice has `items`, DataRecord has `fields`. Walk whichever is + # populated and pick the first scalar with a defaulted value we can + # generate. + children = getattr(params, "items", None) or getattr(params, "fields", None) or [] + for child in children: + ctype = getattr(child, "type", None) + if ctype == "Boolean": + return {child.name: False} + if ctype in ("Count", "Quantity"): + return {child.name: 1} + if ctype in ("Text", "Category"): + return {child.name: "x"} + raise pytest.skip( + f"command schema {params.name!r} has no scalar item we know how to " + f"populate (children types: {[getattr(c, 'type', '?') for c in children]})" + ) + + +@pytest.mark.network +def test_send_command_after_sync_src_to_dest(src_node, dest_node): + """Two-leg test of the command-send path: + + 1. POST a command against the SOURCE node's existing control stream + (where a real driver is registered — for the controllable counter + sample sensor, this exercises actual command execution). + 2. Sync the same control stream's schema to DEST and POST the same + command body to the freshly-inserted copy. Dest may not have a + driver behind the inserted control stream (OSH typically rejects + commands without one); we tolerate that with a clear log line so + the test still proves the source path works end-to-end. + + Either way, the test verifies our `CommandJSON` model serializes to + the wire shape OSH accepts (``parameters`` field, not ``params``). + """ + src_cs = _first_controlstream_with_schema(src_node) + if src_cs is None: + pytest.skip(f"no control streams with schemas found on any system at :{SRC_PORT}") + + cached = src_cs._underlying_resource.command_schema + assert cached is not None, "expected discover_controlstreams to cache command_schema" + payload = _build_command_payload(cached) + print(f"Command payload chosen for schema {cached.params_schema.name!r}: {payload}") + + # --- Leg 1: send to the source's real control stream -------------- + src_api = src_node.get_api_helper() + src_command = CommandJSON(params=payload) + src_resp = src_api.create_resource( + APIResourceTypes.COMMAND, + src_command.to_csapi_dict(), + parent_res_id=src_cs.get_id(), + req_headers={'Content-Type': 'application/json'}, + ) + # CS API Part 2 allows 200 (sync), 201 (created), or 202 (async accepted). + assert src_resp.status_code in (200, 201, 202), ( + f"source command POST returned {src_resp.status_code}: {src_resp.text[:300]}" + ) + print( + f"Source command accepted: HTTP {src_resp.status_code} " + f"(body[:200]={src_resp.text[:200]!r})" + ) + + # --- Leg 2: sync schema to dest, then send to the new control stream + dest_sys, created_dest_sys = _ensure_dest_system(dest_node) + dest_sys_id = dest_sys._resource_id + new_id = None + + try: + src_input_name = src_cs._underlying_resource.input_name or cached.params_schema.name + dest_resource = ControlStreamResource( + cs_id="default", + name=src_cs._underlying_resource.name, + input_name=src_input_name, + command_schema=JSONCommandSchema( + command_format="application/json", + params_schema=cached.params_schema, + ), + valid_time=TimePeriod( + start=TimeInstant.now_as_time_instant(), + end=TimeInstant( + utc_time=TimeUtils.to_utc_time("2026-12-31T00:00:00Z") + ), + ), + ) + new_cs = dest_sys.add_insert_controlstream(dest_resource) + new_id = new_cs.get_id() + assert new_id and new_id != "default" + + dest_api = dest_node.get_api_helper() + dest_command = CommandJSON(params=payload) + dest_resp = dest_api.create_resource( + APIResourceTypes.COMMAND, + dest_command.to_csapi_dict(), + parent_res_id=new_id, + req_headers={'Content-Type': 'application/json'}, + ) + # CS API Part 2 allows 200 (sync), 201 (created), or 202 (async). + # On a freshly-syncd dest with no driver behind the control + # stream, OSH typically returns 202 (queued) rather than 200 + # (executed) — that's still success. + assert dest_resp.status_code in (200, 201, 202), ( + f"dest command POST on control stream {new_id} returned " + f"{dest_resp.status_code}: {dest_resp.text[:300]}" + ) + print( + f"Dest command accepted: HTTP {dest_resp.status_code} " + f"on control stream {new_id} " + f"(body[:200]={dest_resp.text[:200]!r})" + ) + finally: + if new_id: + _delete_resource(dest_node, f"controlstreams/{new_id}") + if created_dest_sys and dest_sys_id: + _delete_resource(dest_node, f"systems/{dest_sys_id}") diff --git a/tests/test_oshconnect.py b/tests/test_oshconnect.py index 3ee042a..c8160c2 100644 --- a/tests/test_oshconnect.py +++ b/tests/test_oshconnect.py @@ -1,84 +1,61 @@ -# ============================================================================== -# Copyright (c) 2024 Botts Innovative Research, Inc. -# Date: 2024/5/28 -# Author: Ian Patterson -# Contact Email: ian@botts-inc.com -# ============================================================================== - -import sys -import os -import websockets - -from oshconnect import TimePeriod, TimeInstant -from src.oshconnect import OSHConnect, Node - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../src'))) - - -class TestOSHConnect: - TEST_PORT = 8282 - - def test_time_period(self): - tp = TimePeriod(start="2024-06-18T15:46:32Z", end="2024-06-18T20:00:00Z") - assert tp is not None - tps = tp.start - tpe = tp.end - assert isinstance(tps, TimeInstant) - assert isinstance(tpe, TimeInstant) - assert tps.epoch_time == TimeInstant.from_string("2024-06-18T15:46:32Z").epoch_time - assert tpe.epoch_time == TimeInstant.from_string("2024-06-18T20:00:00Z").epoch_time - - tp = TimePeriod(start="now", end="2099-06-18T20:00:00Z") - assert tp is not None - assert tp.start == "now" - assert tp.end.epoch_time == TimeInstant.from_string("2099-06-18T20:00:00Z").epoch_time - - tp = TimePeriod(start="2024-06-18T20:00:00Z", end="now") - assert tp is not None - assert tp.start.epoch_time == TimeInstant.from_string("2024-06-18T20:00:00Z").epoch_time - assert tp.end == "now" - - # tp = TimePeriod(start="now", end="now") - - def test_oshconnect_create(self): - app = OSHConnect(name="Test OSH Connect") - assert app is not None - assert app.get_name() == "Test OSH Connect" - - def test_oshconnect_add_node(self): - app = OSHConnect(name="Test OSH Connect") - node = Node(address="http://localhost", port=self.TEST_PORT, protocol="http", username="admin", - password="admin") - # node.add_basicauth("admin", "admin") - app.add_node(node) - assert len(app._nodes) == 1 - assert app._nodes[0] == node - - def test_find_systems(self): - app = OSHConnect(name="Test OSH Connect") - node = Node(address="localhost", port=self.TEST_PORT, username="admin", password="admin", protocol="http") - # node.add_basicauth("admin", "admin") - app.add_node(node) - app.discover_systems() - print(f'Found systems: {app._systems}') - # assert len(systems) == 1 - # assert systems[0] == node.get_api_endpoint() - - def test_oshconnect_find_datastreams(self): - app = OSHConnect(name="Test OSH Connect") - node = Node(address="localhost", port=self.TEST_PORT, username="admin", password="admin", protocol="http") - app.add_node(node) - app.discover_systems() - - app.discover_datastreams() - assert len(app._datastreams) > 0 - - async def test_obs_ws_stream(self): - ds_url = ( - "ws://localhost:8282/sensorhub/api/datastreams/038q16egp1t0/observations?resultTime=latest" - "/2026-01-01T12:00:00Z&f=application%2Fjson") - - # stream = requests.get(ds_url, stream=True, auth=('admin', 'admin')) - async with websockets.connect(ds_url, extra_headers={'Authorization': 'Basic YWRtaW46YWRtaW4='}) as stream: - async for message in stream: - print(message) +"""OSHConnect application object: construction, node attachment, live discovery. + +Tests marked `@pytest.mark.network` require a live OSH server at localhost:8282 +(e.g. FakeWeatherDriver). Skip in CI; see `.github/workflows/tests.yaml`. +""" +import pytest + +from oshconnect import Node, OSHConnect + +TEST_PORT = 8282 + + +def test_oshconnect_constructs_with_name(): + app = OSHConnect(name="Test OSH Connect") + assert app.get_name() == "Test OSH Connect" + + +def test_oshconnect_add_node_appends_to_nodes_list(): + app = OSHConnect(name="Test OSH Connect") + node = Node(address="http://localhost", port=TEST_PORT, protocol="http", + username="admin", password="admin") + app.add_node(node) + assert len(app._nodes) == 1 + assert app._nodes[0] is node + + +# --------------------------------------------------------------------------- +# Live-server tests (network-marked) +# --------------------------------------------------------------------------- + +@pytest.mark.network +def test_discover_systems_against_live_node(): + app = OSHConnect(name="Test OSH Connect") + node = Node(address="localhost", port=TEST_PORT, username="admin", + password="admin", protocol="http") + app.add_node(node) + app.discover_systems() + print(f'Found systems: {app._systems}') + + +@pytest.mark.network +def test_discover_datastreams_against_live_node(): + app = OSHConnect(name="Test OSH Connect") + node = Node(address="localhost", port=TEST_PORT, username="admin", + password="admin", protocol="http") + app.add_node(node) + app.discover_systems() + app.discover_datastreams() + assert len(app._datastreams) > 0 + + +@pytest.mark.network +def test_discover_then_get_datastreams_returns_list(): + app = OSHConnect("Test App") + node = Node(address="localhost", port=TEST_PORT, username="admin", + password="admin", protocol="http") + app.add_node(node) + app.discover_systems() + app.discover_datastreams() + datastreams = app.get_datastreams() + print(datastreams) \ No newline at end of file diff --git a/tests/test_schema_equivalence.py b/tests/test_schema_equivalence.py index 34aba6b..42b0694 100644 --- a/tests/test_schema_equivalence.py +++ b/tests/test_schema_equivalence.py @@ -32,7 +32,7 @@ import requests from src.oshconnect.schema_datamodels import ( - JSONDatastreamRecordSchema, + OMJSONDatastreamRecordSchema, SWEDatastreamRecordSchema, ) @@ -51,7 +51,7 @@ class FormatCase(NamedTuple): CASES = [ FormatCase( obs_format="application/om+json", - model=JSONDatastreamRecordSchema, + model=OMJSONDatastreamRecordSchema, fixture_path=FIXTURES_DIR / "fake_weather_schema_omjson.json", ), FormatCase( diff --git a/tests/test_sensorml.py b/tests/test_sensorml.py new file mode 100644 index 0000000..dd03f66 --- /dev/null +++ b/tests/test_sensorml.py @@ -0,0 +1,244 @@ +"""SensorML 2.0 JSON-encoding structured-field tests. + +Three model classes covered: + +- `Term` (identifiers / classifiers) +- `Characteristics` (CharacteristicList — inner `characteristics` array + of SWE Common components, each requiring a SoftNamedProperty `name`) +- `Capabilities` (CapabilityList — same shape, `capabilities` bucket) + +The fixtures mirror what OSH `:8282` returns under +``?f=application/sml+json`` for the bundled Simulated Weather Sensor. +""" +from __future__ import annotations + +import json + +import pytest +from pydantic import ValidationError + +from oshconnect.resource_datamodels import SystemResource +from oshconnect.sensorml import Capabilities, Characteristics, Term +from oshconnect.swe_components import QuantityRangeSchema, QuantitySchema + + +# --------------------------------------------------------------------------- +# Term +# --------------------------------------------------------------------------- + +def test_term_parses_minimum_required_fields(): + t = Term.model_validate({ + "definition": "http://sensorml.com/ont/swe/property/SerialNumber", + "value": "0123456879", + }) + assert t.definition == "http://sensorml.com/ont/swe/property/SerialNumber" + assert t.value == "0123456879" + assert t.label is None # optional + assert t.code_space is None + + +def test_term_parses_full_osh_shape(): + t = Term.model_validate({ + "definition": "http://sensorml.com/ont/swe/property/SerialNumber", + "label": "Serial Number", + "value": "0123456879", + }) + assert t.label == "Serial Number" + + +def test_term_round_trips_with_codespace_alias(): + src = Term.model_validate({ + "definition": "http://x/def", + "value": "abc", + "codeSpace": "http://x/codes", + }) + assert src.code_space == "http://x/codes" + dumped = src.model_dump(by_alias=True, exclude_none=True) + assert dumped["codeSpace"] == "http://x/codes" + rebuilt = Term.model_validate(dumped) + assert rebuilt == src + + +def test_term_requires_definition(): + with pytest.raises(ValidationError, match="definition"): + Term.model_validate({"value": "abc"}) + + +def test_term_requires_value(): + with pytest.raises(ValidationError, match="value"): + Term.model_validate({"definition": "http://x/def"}) + + +def test_term_extra_fields_round_trip(): + """OSH may add fields the spec hasn't standardized — `extra='allow'` + keeps them on round-trip.""" + src = Term.model_validate({ + "definition": "http://x/def", + "value": "v", + "futureField": "preserved", + }) + dumped = src.model_dump(by_alias=True, exclude_none=True) + assert dumped["futureField"] == "preserved" + + +# --------------------------------------------------------------------------- +# Characteristics +# --------------------------------------------------------------------------- + +OSH_CHARACTERISTICS = { + "definition": "http://www.w3.org/ns/ssn/systems/OperatingRange", + "label": "Operating Characteristics", + "characteristics": [ + {"type": "QuantityRange", "name": "voltage", + "definition": "http://qudt.org/vocab/quantitykind/Voltage", + "label": "Operating Voltage Range", + "uom": {"code": "V"}, "value": [110.0, 250.0]}, + {"type": "QuantityRange", "name": "temperature", + "definition": "http://qudt.org/vocab/quantitykind/Temperature", + "label": "Temperature Range", + "uom": {"code": "Cel"}, "value": [-20.0, 90.0]}, + ], +} + + +def test_characteristics_parses_osh_shape(): + c = Characteristics.model_validate(OSH_CHARACTERISTICS) + assert c.label == "Operating Characteristics" + assert len(c.characteristics) == 2 + # Inner components are routed via AnyComponent's `type` discriminator + # to the right concrete subclass. + assert all(isinstance(x, QuantityRangeSchema) for x in c.characteristics) + assert c.characteristics[0].name == "voltage" + assert c.characteristics[0].value == [110.0, 250.0] + + +def test_characteristics_round_trips_through_json(): + src = Characteristics.model_validate(OSH_CHARACTERISTICS) + dumped = src.model_dump_json(by_alias=True, exclude_none=True) + rebuilt = Characteristics.model_validate(json.loads(dumped)) + # Inner component types still resolve correctly post-round-trip. + assert rebuilt.characteristics[0].name == "voltage" + assert isinstance(rebuilt.characteristics[0], QuantityRangeSchema) + + +def test_characteristics_inner_component_must_carry_name(): + """Inner components are bound via SoftNamedProperty — `name` is + required at the binding site even though it's optional on the + component class itself. Mirrors `DataRecord.fields` and + `Vector.coordinates` validation.""" + payload = { + "definition": "http://x/range", + "characteristics": [ + {"type": "Quantity", # missing `name` + "definition": "http://x/q", + "uom": {"code": "m"}}, + ], + } + with pytest.raises(ValidationError, match="name"): + Characteristics.model_validate(payload) + + +def test_characteristics_definition_and_label_optional(): + """The spec marks `definition` and `label` optional on the list + container itself — only the inner components are required.""" + c = Characteristics.model_validate({ + "characteristics": [ + {"type": "Quantity", "name": "x", + "definition": "http://x/q", "uom": {"code": "m"}}, + ], + }) + assert c.definition is None + assert c.label is None + assert len(c.characteristics) == 1 + + +# --------------------------------------------------------------------------- +# Capabilities (isomorphic to Characteristics, different bucket name) +# --------------------------------------------------------------------------- + +def test_capabilities_parses_with_inner_quantity(): + payload = { + "definition": "http://example.org/caps/Range", + "label": "Sensor Caps", + "capabilities": [ + {"type": "Quantity", "name": "accuracy", + "definition": "http://example.org/Accuracy", + "label": "Accuracy", "uom": {"code": "%"}, + "value": 0.5}, + ], + } + c = Capabilities.model_validate(payload) + assert c.label == "Sensor Caps" + assert isinstance(c.capabilities[0], QuantitySchema) + assert c.capabilities[0].name == "accuracy" + assert c.capabilities[0].value == 0.5 + + +def test_capabilities_inner_component_must_carry_name(): + with pytest.raises(ValidationError, match="name"): + Capabilities.model_validate({ + "capabilities": [ + {"type": "Quantity", "definition": "http://x/q", + "uom": {"code": "m"}}, + ], + }) + + +def test_capabilities_round_trips(): + payload = { + "label": "Caps", + "capabilities": [ + {"type": "Quantity", "name": "speed", + "definition": "http://x/speed", "uom": {"code": "m/s"}, + "value": 12.5}, + ], + } + src = Capabilities.model_validate(payload) + js = src.model_dump_json(by_alias=True, exclude_none=True) + back = Capabilities.model_validate(json.loads(js)) + assert isinstance(back.capabilities[0], QuantitySchema) + assert back.capabilities[0].value == 12.5 + + +# --------------------------------------------------------------------------- +# Integration: SystemResource carrying typed identifiers + characteristics +# --------------------------------------------------------------------------- + +OSH_LIVE_SYSTEM = { + "type": "PhysicalSystem", + "id": "03ie1mkrr9r0", + "uniqueId": "urn:osh:sensor:simweather:0123456879", + "definition": "http://www.w3.org/ns/sosa/Sensor", + "label": "New Simulated Weather Sensor", + "description": "Simulated weather station generating realistic pseudo-random measurements", + "identifiers": [ + {"definition": "http://sensorml.com/ont/swe/property/SerialNumber", + "label": "Serial Number", "value": "0123456879"}, + ], + "validTime": ["2026-04-05T03:54:09.165Z", "now"], + "characteristics": [OSH_CHARACTERISTICS], +} + + +def test_system_resource_typed_identifiers_and_characteristics(): + """End-to-end: parse the OSH live SML+JSON listing payload through + `SystemResource`, assert identifiers/characteristics arrive as the + proper typed models, and that round-trip preserves the structure.""" + s = SystemResource.model_validate(OSH_LIVE_SYSTEM, by_alias=True) + + assert isinstance(s.identifiers[0], Term) + assert s.identifiers[0].value == "0123456879" + + assert isinstance(s.characteristics[0], Characteristics) + inner = s.characteristics[0].characteristics + assert len(inner) == 2 + assert isinstance(inner[0], QuantityRangeSchema) + assert inner[0].name == "voltage" + assert inner[0].value == [110.0, 250.0] + + # Full round-trip: dump → re-parse → same structure. + dumped = s.model_dump(by_alias=True, exclude_none=True, mode='json') + rebuilt = SystemResource.model_validate(dumped, by_alias=True) + assert isinstance(rebuilt.identifiers[0], Term) + assert isinstance(rebuilt.characteristics[0], Characteristics) + assert rebuilt.characteristics[0].characteristics[0].name == "voltage" diff --git a/tests/test_serialization.py b/tests/test_serialization.py deleted file mode 100644 index 71c4530..0000000 --- a/tests/test_serialization.py +++ /dev/null @@ -1,10 +0,0 @@ -from oshconnect import Node - - -def test_node_password_serialization(): - node = Node(protocol='http', address='localhost', port=8080, username='user', password='pass') - serialized = node.serialize() - assert serialized['password'] == 'pass' - deserialized = Node.deserialize(serialized) - assert deserialized._api_helper.password == 'pass' - diff --git a/tests/test_streamable_resources.py b/tests/test_streamable_resources.py deleted file mode 100644 index f5fe182..0000000 --- a/tests/test_streamable_resources.py +++ /dev/null @@ -1,12 +0,0 @@ -from oshconnect import OSHConnect, Node - - -def test_streamble_observations(): - app = OSHConnect("Test App") - node = Node(address="localhost", port=8282, username="admin", password="admin", protocol="http") - app.add_node(node) - app.discover_systems() - app.discover_datastreams() - - datastreams = app.get_datastreams() - print(datastreams) \ No newline at end of file diff --git a/tests/test_swe_binary.py b/tests/test_swe_binary.py new file mode 100644 index 0000000..2fcac55 --- /dev/null +++ b/tests/test_swe_binary.py @@ -0,0 +1,622 @@ +# ============================================================================= +# Copyright (c) 2026 Georobotix Innovative Research +# Date: 2026/5/19 +# Author: Ian Patterson +# Contact Email: ian.patterson@georobotix.us +# ============================================================================= + +"""Tests for the SWE Common BinaryEncoding wire codec. + +Two layers: + +1. Unit tests (default) — exercise the wire spec against hand-built bytes, + schemas built from dicts (matching the live response shapes documented in + ``docs/AXIS_CAMERA_FORMATS.md`` in the OGC code-sprint demo repo), and the + `SWEBinaryCodec` round-trip path. + +2. Network tests (``-m network``) — hit a live Axis-camera-backed OSH node on + ``localhost:9191`` (overridable via ``OSHC_AXIS_PORT``) to verify the SDK + negotiates the binary schema variant during discovery and that the codec + decodes real observations off the live datastream. +""" +from __future__ import annotations + +import os +import struct +import time + +import pytest +import requests + +from oshconnect.encoding import BinaryBlockMember, BinaryComponentMember, BinaryEncoding +from oshconnect.schema_datamodels import ( + SWEBinaryDatastreamRecordSchema, + SWEDatastreamRecordSchema, +) +from oshconnect.swe_binary import ( + DATATYPE_STRUCT_FMT, + SWEBinaryCodec, + decode_swe_binary_blob, + decode_swe_binary_record, + encode_swe_binary_blob, + encode_swe_binary_record, +) + + +# --------------------------------------------------------------------------- +# Low-level helpers +# --------------------------------------------------------------------------- + + +def test_blob_round_trip_basic(): + """[ts][size][payload] round-trip with an opaque payload.""" + payload = b"\x00\x00\x00\x01" + b"\xab" * 64 # H.264-shaped opaque bytes + framed = encode_swe_binary_blob(payload, ts=1_700_000_000.5) + assert framed.startswith(struct.pack(">d", 1_700_000_000.5)) + assert struct.unpack(">I", framed[8:12])[0] == len(payload) + ts, decoded = decode_swe_binary_blob(framed) + assert ts == pytest.approx(1_700_000_000.5) + assert decoded == payload + + +def test_blob_default_timestamp_is_close_to_now(): + before = time.time() + framed = encode_swe_binary_blob(b"xxx") + after = time.time() + ts, _ = decode_swe_binary_blob(framed) + assert before - 1 <= ts <= after + 1 + + +def test_blob_decode_rejects_truncated_header(): + with pytest.raises(ValueError, match="too short"): + decode_swe_binary_blob(b"\x00" * 11) + + +def test_blob_decode_rejects_truncated_payload(): + # declares 100 bytes of payload, supplies 10 + bad = struct.pack(">dI", 0.0, 100) + b"\x00" * 10 + with pytest.raises(ValueError, match="truncated"): + decode_swe_binary_blob(bad) + + +def test_fixed_record_round_trip_default_float32(): + """Matches the ptzOutput wire form: [ts][f32][f32][f32].""" + raw = encode_swe_binary_record(1_779_218_475.807, -6.7, 0.0, 1.0) + assert len(raw) == 8 + 3 * 4 + ts, pan, tilt, zoom = decode_swe_binary_record(raw, n_values=3) + assert ts == pytest.approx(1_779_218_475.807, rel=1e-9) + assert pan == pytest.approx(-6.7, rel=1e-5) + assert tilt == pytest.approx(0.0) + assert zoom == pytest.approx(1.0) + + +def test_fixed_record_with_doubles(): + raw = encode_swe_binary_record(1.0, 2.0, 3.0, fmt="d") + assert len(raw) == 8 + 2 * 8 + out = decode_swe_binary_record(raw, n_values=2, fmt="d") + assert out == (1.0, 2.0, 3.0) + + +# --------------------------------------------------------------------------- +# Schema-driven codec +# --------------------------------------------------------------------------- + + +PTZ_SCHEMA_DICT = { + "obsFormat": "application/swe+binary", + "recordSchema": { + "type": "DataRecord", + "name": "ptz", + "fields": [ + {"type": "Time", "name": "time", + "definition": "http://www.opengis.net/def/property/OGC/0/SamplingTime", + "uom": {"href": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"}}, + {"type": "Quantity", "name": "pan", + "definition": "http://sensorml.com/ont/swe/property/Pan", + "uom": {"code": "deg"}}, + {"type": "Quantity", "name": "tilt", + "definition": "http://sensorml.com/ont/swe/property/Tilt", + "uom": {"code": "deg"}}, + {"type": "Quantity", "name": "zoomFactor", + "definition": "http://sensorml.com/ont/swe/property/Zoom", + "uom": {"code": "1"}}, + ], + }, + "recordEncoding": { + "type": "BinaryEncoding", + "byteOrder": "bigEndian", + "byteEncoding": "raw", + "members": [ + {"type": "Component", "ref": "/time", + "dataType": "http://www.opengis.net/def/dataType/OGC/0/double"}, + {"type": "Component", "ref": "/pan", + "dataType": "http://www.opengis.net/def/dataType/OGC/0/float32"}, + {"type": "Component", "ref": "/tilt", + "dataType": "http://www.opengis.net/def/dataType/OGC/0/float32"}, + {"type": "Component", "ref": "/zoomFactor", + "dataType": "http://www.opengis.net/def/dataType/OGC/0/float32"}, + ], + }, +} + + +VIDEO_SCHEMA_DICT = { + "obsFormat": "application/swe+binary", + "recordSchema": { + "type": "DataRecord", + "name": "video", + "fields": [ + {"type": "Time", "name": "time", + "definition": "http://www.opengis.net/def/property/OGC/0/SamplingTime", + "uom": {"href": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"}}, + # The recordSchema describes the abstract shape (raster); the + # recordEncoding overrides it with an opaque Block. We model the + # abstract side as a single Count here so the test schema parses + # without the full DataArray-of-DataArray nesting — the codec only + # cares about recordEncoding.members. + {"type": "Count", "name": "img", + "definition": "http://sensorml.com/ont/swe/property/RasterImage"}, + ], + }, + "recordEncoding": { + "type": "BinaryEncoding", + "byteOrder": "bigEndian", + "byteEncoding": "raw", + "members": [ + {"type": "Component", "ref": "/time", + "dataType": "http://www.opengis.net/def/dataType/OGC/0/double"}, + {"type": "Block", "ref": "/img", "compression": "H264"}, + ], + }, +} + + +def test_parse_ptz_schema(): + schema = SWEBinaryDatastreamRecordSchema.from_swebinary_dict(PTZ_SCHEMA_DICT) + assert schema.obs_format == "application/swe+binary" + assert isinstance(schema.record_encoding, BinaryEncoding) + assert len(schema.record_encoding.members) == 4 + # discriminated union resolves to the right concrete subclass + assert isinstance(schema.record_encoding.members[0], BinaryComponentMember) + + +def test_parse_video_schema_has_block_member(): + schema = SWEBinaryDatastreamRecordSchema.from_swebinary_dict(VIDEO_SCHEMA_DICT) + members = schema.record_encoding.members + assert isinstance(members[0], BinaryComponentMember) + assert isinstance(members[1], BinaryBlockMember) + assert members[1].compression == "H264" + + +def test_codec_round_trip_ptz_record(): + schema = SWEBinaryDatastreamRecordSchema.from_swebinary_dict(PTZ_SCHEMA_DICT) + codec = SWEBinaryCodec(schema) + assert codec.field_names == ["time", "pan", "tilt", "zoomFactor"] + payload = codec.encode({"time": 1_779_218_475.807, "pan": -6.7, + "tilt": 0.0, "zoomFactor": 1.0}) + assert len(payload) == 8 + 3 * 4 + out = codec.decode(payload) + assert out["time"] == pytest.approx(1_779_218_475.807, rel=1e-9) + assert out["pan"] == pytest.approx(-6.7, rel=1e-5) + assert out["tilt"] == pytest.approx(0.0) + assert out["zoomFactor"] == pytest.approx(1.0) + + +def test_codec_accepts_positional_sequence(): + schema = SWEBinaryDatastreamRecordSchema.from_swebinary_dict(PTZ_SCHEMA_DICT) + codec = SWEBinaryCodec(schema) + by_mapping = codec.encode({"time": 1.0, "pan": 2.0, + "tilt": 3.0, "zoomFactor": 4.0}) + by_sequence = codec.encode([1.0, 2.0, 3.0, 4.0]) + assert by_mapping == by_sequence + + +def test_codec_round_trip_video_block(): + schema = SWEBinaryDatastreamRecordSchema.from_swebinary_dict(VIDEO_SCHEMA_DICT) + codec = SWEBinaryCodec(schema) + fake_nal = b"\x00\x00\x00\x01" + b"\x67" + b"\xab" * 100 + payload = codec.encode({"time": 1_700_000_000.0, "img": fake_nal}) + # Wire: 8 (ts) + 4 (size prefix) + len(fake_nal) + assert len(payload) == 8 + 4 + len(fake_nal) + out = codec.decode(payload) + assert out["time"] == pytest.approx(1_700_000_000.0) + assert out["img"] == fake_nal + assert isinstance(out["img"], bytes) + + +def test_codec_round_trip_concatenated_records(): + """`decode_with_offset` should walk multiple records in one buffer.""" + schema = SWEBinaryDatastreamRecordSchema.from_swebinary_dict(PTZ_SCHEMA_DICT) + codec = SWEBinaryCodec(schema) + buf = b"" + expected = [ + {"time": 1.0, "pan": 2.0, "tilt": 3.0, "zoomFactor": 4.0}, + {"time": 5.0, "pan": 6.0, "tilt": 7.0, "zoomFactor": 8.0}, + {"time": 9.0, "pan": 10.0, "tilt": 11.0, "zoomFactor": 12.0}, + ] + for rec in expected: + buf += codec.encode(rec) + offset = 0 + decoded = [] + while offset < len(buf): + rec, offset = codec.decode_with_offset(buf, offset=offset) + decoded.append(rec) + assert offset == len(buf) + assert len(decoded) == 3 + for got, want in zip(decoded, expected): + for k in want: + assert got[k] == pytest.approx(want[k]) + + +def test_codec_rejects_unknown_datatype(): + bad = dict(PTZ_SCHEMA_DICT) + bad["recordEncoding"] = { + **bad["recordEncoding"], + "members": [ + {"type": "Component", "ref": "/time", + "dataType": "http://example.com/dataType/OGC/0/zebra"}, + ], + } + schema = SWEBinaryDatastreamRecordSchema.from_swebinary_dict(bad) + with pytest.raises(ValueError, match="unsupported dataType"): + SWEBinaryCodec(schema) + + +def test_codec_rejects_base64_byte_encoding(): + bad = dict(PTZ_SCHEMA_DICT) + bad["recordEncoding"] = {**bad["recordEncoding"], "byteEncoding": "base64"} + schema = SWEBinaryDatastreamRecordSchema.from_swebinary_dict(bad) + with pytest.raises(NotImplementedError, match="base64"): + SWEBinaryCodec(schema) + + +def test_codec_honours_little_endian(): + little = dict(PTZ_SCHEMA_DICT) + little["recordEncoding"] = {**little["recordEncoding"], + "byteOrder": "littleEndian"} + schema = SWEBinaryDatastreamRecordSchema.from_swebinary_dict(little) + codec = SWEBinaryCodec(schema) + payload = codec.encode([1.0, 2.0, 3.0, 4.0]) + # First 8 bytes should pack as little-endian double + expected_ts = struct.pack("I", wire[:4])[0] == 4 + out = decode_swe_binary_scalar_array(wire, uri, variable_size=True) + assert out == [7, 11, 13, 17] + + +def test_default_datatype_for_schema(): + """Mirrors OSH's `SWEHelper.getDefaultBinaryEncoding`: Quantity->double, + Count->signedInt, Boolean->boolean, Time->double.""" + from oshconnect.swe_binary import default_datatype_for_schema + from oshconnect.swe_components import ( + BooleanSchema, CountSchema, QuantitySchema, TimeSchema, + ) + from oshconnect.api_utils import UCUMCode, URI + q = QuantitySchema(name='x', label='X', + definition='http://example.org/x', + uom=UCUMCode(code='m', label='m')) + c = CountSchema(name='n', label='N', + definition='http://example.org/n', + uom=UCUMCode(code='1', label='1')) + b = BooleanSchema(name='b', label='B', + definition='http://example.org/b') + t = TimeSchema(name='t', label='T', + definition='http://www.opengis.net/def/property/OGC/0/SamplingTime', + uom=URI(href='http://www.opengis.net/def/uom/ISO-8601/0/Gregorian')) + assert default_datatype_for_schema(q).endswith("/double") + assert default_datatype_for_schema(c).endswith("/signedInt") + assert default_datatype_for_schema(b).endswith("/boolean") + assert default_datatype_for_schema(t).endswith("/double") + + +def test_datatype_table_is_complete_for_common_widths(): + # Spot-check the most-seen URIs from real OSH wire payloads + assert DATATYPE_STRUCT_FMT[ + "http://www.opengis.net/def/dataType/OGC/0/double"] == "d" + assert DATATYPE_STRUCT_FMT[ + "http://www.opengis.net/def/dataType/OGC/0/float32"] == "f" + + +# --------------------------------------------------------------------------- +# Discriminated-union dispatch (Datastream side) +# --------------------------------------------------------------------------- + + +def test_anydatastreamrecordschema_dispatches_to_binary(): + """`AnyDatastreamRecordSchema` should route `obsFormat=swe+binary` to + `SWEBinaryDatastreamRecordSchema`, not the JSON-family one.""" + from oshconnect.resource_datamodels import DatastreamResource + + payload = { + "id": "ds-1", + "name": "test-binary", + "validTime": ["2026-01-01T00:00:00Z", "2099-01-01T00:00:00Z"], + "schema": PTZ_SCHEMA_DICT, + "formats": ["application/swe+binary"], + } + ds = DatastreamResource.model_validate(payload, by_alias=True) + assert isinstance(ds.record_schema, SWEBinaryDatastreamRecordSchema) + + +def test_anydatastreamrecordschema_still_dispatches_to_json(): + """Regression guard: JSON variant must keep parsing as before.""" + from oshconnect.resource_datamodels import DatastreamResource + + json_schema = { + "obsFormat": "application/swe+json", + "recordSchema": { + "type": "DataRecord", "name": "test", + "fields": [ + {"type": "Time", "name": "time", + "definition": "http://www.opengis.net/def/property/OGC/0/SamplingTime", + "uom": {"href": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"}}, + ], + }, + } + payload = { + "id": "ds-2", + "name": "test-json", + "validTime": ["2026-01-01T00:00:00Z", "2099-01-01T00:00:00Z"], + "schema": json_schema, + "formats": ["application/swe+json"], + } + ds = DatastreamResource.model_validate(payload, by_alias=True) + assert isinstance(ds.record_schema, SWEDatastreamRecordSchema) + + +# --------------------------------------------------------------------------- +# Datastream.insert / decode_observation dispatch +# --------------------------------------------------------------------------- + + +class _StubNode: + """Minimal `Node` stand-in for unit tests that don't need a real broker.""" + def register_streamable(self, _streamable): + pass + + def get_mqtt_client(self): + return None + + +def _make_binary_datastream(): + """Build a Datastream wired to a swe+binary schema, with the MQTT publish + side stubbed so we can capture the wire bytes without a broker.""" + from oshconnect.resource_datamodels import DatastreamResource + from oshconnect.resources.datastream import Datastream + + ds_resource = DatastreamResource.model_validate({ + "id": "ds-bin", + "name": "bin", + "validTime": ["2026-01-01T00:00:00Z", "2099-01-01T00:00:00Z"], + "schema": PTZ_SCHEMA_DICT, + "formats": ["application/swe+binary"], + }, by_alias=True) + ds = Datastream(parent_node=_StubNode(), datastream_resource=ds_resource) + captured: list[bytes] = [] + ds._topic = "test-topic" + ds._publish_mqtt = lambda topic, payload: captured.append(payload) + return ds, captured + + +def test_datastream_insert_routes_through_binary_codec(): + ds, captured = _make_binary_datastream() + ds.insert({"time": 1.0, "pan": 2.0, "tilt": 3.0, "zoomFactor": 4.0}) + assert len(captured) == 1 + assert len(captured[0]) == 8 + 3 * 4 + # First 8 bytes are big-endian double 1.0 + assert struct.unpack(">d", captured[0][:8])[0] == 1.0 + + +def test_datastream_insert_passes_bytes_through(): + ds, captured = _make_binary_datastream() + pre_framed = encode_swe_binary_blob(b"hi", ts=1.0) + ds.insert(pre_framed) + assert captured == [pre_framed] + + +def test_datastream_decode_observation_uses_binary_codec(): + ds, _ = _make_binary_datastream() + framed = struct.pack(">d3f", 7.0, 8.0, 9.0, 10.0) + out = ds.decode_observation(framed) + assert out["time"] == pytest.approx(7.0) + assert out["pan"] == pytest.approx(8.0) + + +def test_datastream_decode_observation_without_schema_raises(): + from oshconnect.resource_datamodels import DatastreamResource + from oshconnect.resources.datastream import Datastream + ds_resource = DatastreamResource.model_validate({ + "id": "ds-noschema", "name": "x", + "validTime": ["2026-01-01T00:00:00Z", "2099-01-01T00:00:00Z"], + }, by_alias=True) + ds = Datastream(parent_node=_StubNode(), datastream_resource=ds_resource) + with pytest.raises(ValueError, match="no record_schema"): + ds.decode_observation(b"\x00" * 12) + + +# --------------------------------------------------------------------------- +# Format picker (System.discover_datastreams helper) +# --------------------------------------------------------------------------- + + +def test_pick_schema_format_prefers_swe_json(): + from oshconnect.resources.system import System + obs_fmt, parser = System._pick_datastream_schema_format([ + "application/om+json", "application/swe+json", "application/swe+binary", + ]) + assert obs_fmt == "application/swe+json" + # Bound classmethods aren't identity-equal across accesses; compare by name. + assert parser.__func__ is SWEDatastreamRecordSchema.from_swejson_dict.__func__ + + +def test_pick_schema_format_falls_back_to_binary(): + from oshconnect.resources.system import System + obs_fmt, parser = System._pick_datastream_schema_format([ + "application/om+json", "application/swe+binary", + ]) + assert obs_fmt == "application/swe+binary" + assert parser.__func__ is SWEBinaryDatastreamRecordSchema.from_swebinary_dict.__func__ + + +def test_pick_schema_format_returns_none_when_nothing_supported(): + from oshconnect.resources.system import System + obs_fmt, parser = System._pick_datastream_schema_format([ + "application/om+json", "application/swe+csv", + ]) + assert obs_fmt is None and parser is None + + +# --------------------------------------------------------------------------- +# Network tests (require a live Axis-camera-backed OSH node) +# --------------------------------------------------------------------------- + + +AXIS_PORT = os.environ.get("OSHC_AXIS_PORT", "9191") +AXIS_BASE = f"http://localhost:{AXIS_PORT}/sensorhub/api" + + +def _axis_node_reachable() -> bool: + try: + r = requests.get(f"{AXIS_BASE}/systems", timeout=2) + return r.ok + except Exception: + return False + + +pytestmark_network_axis = pytest.mark.skipif( + not _axis_node_reachable(), + reason=f"Axis OSH node not reachable at {AXIS_BASE}", +) + + +@pytest.mark.network +@pytestmark_network_axis +def test_live_axis_video_schema_parses(): + """Pull the live `040g`/video1 schema (swe+binary only) and parse it.""" + resp = requests.get( + f"{AXIS_BASE}/datastreams/040g/schema", + params={"obsFormat": "application/swe+binary"}, + timeout=5, + ) + resp.raise_for_status() + schema = SWEBinaryDatastreamRecordSchema.from_swebinary_dict(resp.json()) + assert schema.obs_format == "application/swe+binary" + # Members include a block for /img with H264 compression + block_members = [m for m in schema.record_encoding.members + if isinstance(m, BinaryBlockMember)] + assert any(m.compression == "H264" for m in block_members) + + +@pytest.mark.network +@pytestmark_network_axis +def test_live_axis_video_observation_decodes(): + """Fetch one live H.264 frame via swe+binary and verify the frame's + NAL start code survives the codec round-trip.""" + schema_resp = requests.get( + f"{AXIS_BASE}/datastreams/040g/schema", + params={"obsFormat": "application/swe+binary"}, + timeout=5, + ) + schema_resp.raise_for_status() + schema = SWEBinaryDatastreamRecordSchema.from_swebinary_dict(schema_resp.json()) + obs_resp = requests.get( + f"{AXIS_BASE}/datastreams/040g/observations", + params={"f": "application/swe+binary", "limit": 1}, + timeout=5, + ) + obs_resp.raise_for_status() + codec = SWEBinaryCodec(schema) + record = codec.decode(obs_resp.content) + assert "time" in record + img = record["img"] + assert isinstance(img, bytes) and len(img) > 100 + # Annex B start code for H.264 + assert img[:4] == b"\x00\x00\x00\x01" + + +@pytest.mark.network +@pytestmark_network_axis +def test_live_axis_ptz_observation_round_trip(): + """Pull a ptzOutput swe+binary record and a swe+json record from the + same datastream and check the numbers agree across formats.""" + schema_resp = requests.get( + f"{AXIS_BASE}/datastreams/0410/schema", + params={"obsFormat": "application/swe+binary"}, + timeout=5, + ) + schema_resp.raise_for_status() + schema = SWEBinaryDatastreamRecordSchema.from_swebinary_dict(schema_resp.json()) + codec = SWEBinaryCodec(schema) + bin_resp = requests.get( + f"{AXIS_BASE}/datastreams/0410/observations", + params={"f": "application/swe+binary", "limit": 1}, + timeout=5, + ) + bin_resp.raise_for_status() + bin_record = codec.decode(bin_resp.content) + # Fields we expect from the doc: time, pan, tilt, zoomFactor + for k in ("time", "pan", "tilt", "zoomFactor"): + assert k in bin_record + + +@pytest.mark.network +@pytestmark_network_axis +def test_live_axis_discovery_picks_binary_for_video(): + """Full discovery against the live Axis node: System.discover_datastreams + must pick `application/swe+binary` for the video output (which doesn't + advertise swe+json) and end up with a `SWEBinaryDatastreamRecordSchema`.""" + from oshconnect import Node + + # Go through Node directly — `OSHConnect.discover_systems()` mutates state + # rather than returning the discovered list, and we just want the systems. + node = Node(protocol="http", address="localhost", port=int(AXIS_PORT)) + systems = node.discover_systems() + assert systems, "Expected at least one system on the Axis node" + found_binary = False + for sys in systems: + for ds in sys.discover_datastreams(): + schema = ds.get_resource().record_schema + if isinstance(schema, SWEBinaryDatastreamRecordSchema): + found_binary = True + # If this is video1, codec.decode of a fetched obs must work + codec = SWEBinaryCodec(schema) + obs_resp = requests.get( + f"{AXIS_BASE}/datastreams/{ds.get_id()}/observations", + params={"f": "application/swe+binary", "limit": 1}, + timeout=5, + ) + if obs_resp.ok and obs_resp.content: + codec.decode(obs_resp.content) + break + if found_binary: + break + assert found_binary, ( + "Discovery did not produce any SWEBinaryDatastreamRecordSchema; " + "format-aware schema fetch is not engaging on the live node." + ) diff --git a/tests/test_swe_components.py b/tests/test_swe_components.py new file mode 100644 index 0000000..08693e8 --- /dev/null +++ b/tests/test_swe_components.py @@ -0,0 +1,650 @@ +"""SWE Common 3 component models: validators, structural rules, round-trip. + +Two sections: + + A. SoftNamedProperty `name` validation — `name` is required wherever a + component is bound (DataRecord.fields, DataChoice.items, Vector.coordinates, + DataArray/Matrix.elementType, and the root recordSchema/resultSchema of a + datastream/controlstream). Names must match NameToken + `^[A-Za-z][A-Za-z0-9_\\-]*$`. Standalone components do NOT require a name. + + B. Schema conformance — spec-required fields per leaf type, discriminator + routing, alias/snake_case parity, round-trip fidelity, Vector.coordinates + element-type restriction, DataRecord.fields minItems:1. + +Both sections are anchored against the canonical JSON schemas at: +https://github.com/opengeospatial/ogcapi-connected-systems/tree/master/swecommon/schemas/json +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from pydantic import TypeAdapter, ValidationError + +from oshconnect.schema_datamodels import ( + JSONCommandSchema, + OMJSONDatastreamRecordSchema, + SWEDatastreamRecordSchema, + SWEJSONCommandSchema, +) +from oshconnect.swe_components import ( + AnyComponent, + BooleanSchema, + CategoryRangeSchema, + CategorySchema, + CountRangeSchema, + CountSchema, + DataArraySchema, + DataChoiceSchema, + DataRecordSchema, + GeometrySchema, + MatrixSchema, + QuantityRangeSchema, + QuantitySchema, + TextSchema, + TimeRangeSchema, + TimeSchema, + VectorSchema, +) + +FIXTURES_DIR = Path(__file__).parent / "fixtures" +ANY_COMPONENT = TypeAdapter(AnyComponent) + + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +VALID_TIME_FIELD = { + "type": "Time", + "name": "time", + "label": "Sampling Time", + "definition": "http://www.opengis.net/def/property/OGC/0/SamplingTime", + "uom": {"href": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"}, +} +VALID_TEMP_FIELD = { + "type": "Quantity", + "name": "temperature", + "label": "Air Temperature", + "definition": "http://mmisw.org/ont/cf/parameter/air_temperature", + "uom": {"code": "Cel"}, +} + + +def _quantity_field(name: str = "x") -> dict: + return { + "type": "Quantity", + "name": name, + "label": "X", + "definition": "http://example.org/x", + "uom": {"code": "m"}, + } + + +# =========================================================================== +# A. SoftNamedProperty `name` validation +# =========================================================================== + +# --- A.1 standalone components don't need a name --------------------------- + +def test_quantity_standalone_no_name_ok(): + q = QuantitySchema(label="Air Temperature", + definition="http://example.org/temperature", + uom={"code": "Cel"}) + assert q.name is None + + +def test_vector_standalone_no_name_ok(): + v = VectorSchema( + label="Position", definition="http://example.org/position", + referenceFrame="http://example.org/frames/ENU", + coordinates=[ + QuantitySchema(name="x", label="X", + definition="http://example.org/x", uom={"code": "m"}), + QuantitySchema(name="y", label="Y", + definition="http://example.org/y", uom={"code": "m"}), + ], + ) + assert v.name is None + + +# --- A.2 fixtures: round-trip preserves names ------------------------------ + +def test_swejson_fixture_preserves_names_on_round_trip(): + raw = json.loads((FIXTURES_DIR / "fake_weather_schema_swejson.json").read_text()) + parsed = SWEDatastreamRecordSchema.model_validate(raw) + re_dumped = parsed.model_dump(mode="json", by_alias=True, exclude_none=True) + assert re_dumped["recordSchema"]["name"] == "weather" + assert {f["name"] for f in re_dumped["recordSchema"]["fields"]} == { + "time", "temperature", "pressure", "windSpeed", "windDirection" + } + + +def test_omjson_fixture_preserves_names_on_round_trip(): + raw = json.loads((FIXTURES_DIR / "fake_weather_schema_omjson.json").read_text()) + parsed = OMJSONDatastreamRecordSchema.model_validate(raw) + re_dumped = parsed.model_dump(mode="json", by_alias=True, exclude_none=True) + assert re_dumped["resultSchema"]["name"] == "weather" + + +# --- A.3 binding contexts require name on each child ----------------------- + +def test_record_with_named_fields_ok(): + DataRecordSchema(name="weather", + fields=[VALID_TIME_FIELD, VALID_TEMP_FIELD]) + + +def test_record_field_missing_name_raises(): + with pytest.raises(ValidationError, match="DataRecord.fields"): + DataRecordSchema(name="weather", fields=[ + {"type": "Quantity", "label": "X", + "definition": "http://example.org/x", "uom": {"code": "Cel"}}, + ]) + + +def test_choice_items_named_ok(): + DataChoiceSchema( + name="alt", + choiceValue=CategorySchema(name="picker", label="Picker", + definition="http://example.org/picker", + value="a"), + items=[_quantity_field("alt_a")], + ) + + +def test_choice_item_missing_name_raises(): + with pytest.raises(ValidationError, match="DataChoice.items"): + DataChoiceSchema( + name="alt", + choiceValue=CategorySchema(name="picker", label="Picker", + definition="http://example.org/picker", + value="a"), + items=[ + {"type": "Quantity", "label": "X", + "definition": "http://example.org/x", "uom": {"code": "m"}}, + ], + ) + + +def test_vector_coordinate_missing_name_raises(): + with pytest.raises(ValidationError, match="Vector.coordinates"): + VectorSchema( + label="Position", definition="http://example.org/position", + referenceFrame="http://example.org/frames/ENU", + coordinates=[ + {"type": "Quantity", "label": "X", + "definition": "http://example.org/x", "uom": {"code": "m"}}, + ], + ) + + +def test_dataarray_element_type_missing_name_raises(): + with pytest.raises(ValidationError, match="DataArray.elementType"): + DataArraySchema( + elementCount={"type": "Count", "name": "n", "label": "n", + "definition": "http://example.org/n"}, + elementType={"type": "Quantity", "label": "X", + "definition": "http://example.org/x", "uom": {"code": "m"}}, + encoding="JSONEncoding", + ) + + +def test_matrix_element_type_missing_name_raises(): + with pytest.raises(ValidationError, match="Matrix.elementType"): + MatrixSchema( + elementCount={"type": "Count", "name": "n", "label": "n", + "definition": "http://example.org/n"}, + elementType=[ + {"type": "Quantity", "label": "X", + "definition": "http://example.org/x", "uom": {"code": "m"}}, + ], + encoding="JSONEncoding", + ) + + +# --- A.4 datastream/controlstream wrappers: root requires name ------------- + +def test_swe_datastream_root_requires_name(): + with pytest.raises(ValidationError, match="SWEDatastreamRecordSchema.recordSchema"): + SWEDatastreamRecordSchema.model_validate({ + "obsFormat": "application/swe+json", + "recordSchema": { + "type": "DataRecord", + "definition": "urn:osh:data:weather", + "fields": [VALID_TIME_FIELD], + }, + }) + + +def test_json_datastream_optional_when_no_schemas_present(): + # Per CS API Part 2 §16.1.4, JSON form may use resultLink instead of + # inline schemas, so neither resultSchema nor parametersSchema is required. + OMJSONDatastreamRecordSchema.model_validate({"obsFormat": "application/json"}) + + +def test_json_datastream_result_schema_requires_name_when_present(): + with pytest.raises(ValidationError, match="OMJSONDatastreamRecordSchema.resultSchema"): + OMJSONDatastreamRecordSchema.model_validate({ + "obsFormat": "application/json", + "resultSchema": { + "type": "DataRecord", + "definition": "urn:osh:data:weather", + "fields": [VALID_TIME_FIELD], + }, + }) + + +def test_swe_command_schema_root_requires_name(): + with pytest.raises(ValidationError, match="SWEJSONCommandSchema.recordSchema"): + SWEJSONCommandSchema.model_validate({ + "commandFormat": "application/swe+json", + "encoding": {"type": "JSONEncoding"}, + "recordSchema": { + "type": "DataRecord", + "definition": "urn:osh:control:cmd", + "fields": [VALID_TIME_FIELD], + }, + }) + + +def test_json_command_schema_params_requires_name(): + with pytest.raises(ValidationError, match="JSONCommandSchema.parametersSchema"): + JSONCommandSchema.model_validate({ + "commandFormat": "application/json", + "parametersSchema": { + "type": "DataRecord", + "definition": "urn:osh:control:params", + "fields": [VALID_TIME_FIELD], + }, + }) + + +def test_nested_aggregate_in_record_fields_validated(): + # Aggregate-in-aggregate: a DataRecord inside another DataRecord's fields[]. + # The inner record must itself be named (it's the bound child); its own + # fields are validated by the inner record's validator independently. + DataRecordSchema(name="outer", fields=[ + {"type": "DataRecord", "name": "inner", "fields": [VALID_TIME_FIELD]}, + ]) + with pytest.raises(ValidationError, match="DataRecord.fields"): + DataRecordSchema(name="outer", fields=[ + {"type": "DataRecord", "fields": [VALID_TIME_FIELD]}, + ]) + + +# --- A.5 NameToken pattern ------------------------------------------------- + +@pytest.mark.parametrize("good_name", + ["a", "ab", "wind_speed", "wind-speed", "x1", "X_1-y"]) +def test_valid_name_tokens_accepted(good_name): + DataRecordSchema(name="root", fields=[_quantity_field(good_name)]) + + +@pytest.mark.parametrize("bad_name", + ["", "1leading", "with space", "with:colon", + "with.dot", "with/slash"]) +def test_invalid_name_tokens_rejected(bad_name): + with pytest.raises(ValidationError): + DataRecordSchema(name="root", fields=[_quantity_field(bad_name)]) + + +def test_swe_datastream_root_invalid_name_pattern_raises(): + with pytest.raises(ValidationError, match="NameToken"): + SWEDatastreamRecordSchema.model_validate({ + "obsFormat": "application/swe+json", + "recordSchema": { + "type": "DataRecord", + "name": "1bad-leading-digit", + "definition": "urn:osh:data:weather", + "fields": [VALID_TIME_FIELD], + }, + }) + + +# =========================================================================== +# B. Schema conformance +# =========================================================================== + +# --- B.1 spec `required` arrays per leaf type ------------------------------ +# Per the JSON schemas, required arrays per type: +# Quantity: [type, definition, label, uom] +# Boolean: [type, definition, label] +# Text: [type, definition, label] +# Vector: [type, definition, referenceFrame, coordinates] +# DataRecord:[type, fields] +# Geometry: [type, srs, definition] +# +# `label` is optional everywhere — SWE Common 3 inherits it from +# AbstractDataComponent as optional. OSH emits labelless components +# in the wild (e.g. the SensorLocation Vector); a required `label` +# here would break record-schema parsing during discovery. + + +def test_quantity_requires_uom(): + with pytest.raises(ValidationError, match="uom"): + QuantitySchema(label="X", definition="http://example.org/x") + + +def test_quantity_label_is_optional(): + q = QuantitySchema(definition="http://example.org/x", uom={"code": "m"}) + assert q.label is None + + +def test_quantity_requires_definition(): + with pytest.raises(ValidationError, match="definition"): + QuantitySchema(label="X", uom={"code": "m"}) + + +def test_boolean_label_optional_definition_required(): + BooleanSchema(definition="http://example.org/b") # no label — OK + with pytest.raises(ValidationError, match="definition"): + BooleanSchema(label="X") + + +def test_text_label_optional_definition_required(): + TextSchema(definition="http://example.org/t") # no label — OK + with pytest.raises(ValidationError, match="definition"): + TextSchema(label="X") + + +def test_vector_requires_definition_referenceframe_coordinates(): + # `label` is intentionally NOT in the required set: SWE Common 3 inherits + # it from AbstractDataComponent as optional, and OSH emits labelless + # Vectors (e.g. SensorLocation). See test_vector_label_is_optional… + base = dict( + label="V", definition="http://example.org/v", + referenceFrame="http://example.org/frames/ENU", + coordinates=[QuantitySchema(name="x", label="X", + definition="http://example.org/x", + uom={"code": "m"})], + ) + for missing in ("definition", "referenceFrame", "coordinates"): + kwargs = {k: v for k, v in base.items() if k != missing} + with pytest.raises(ValidationError): + VectorSchema(**kwargs) + + +def test_datarecord_requires_fields(): + with pytest.raises(ValidationError, match="fields"): + DataRecordSchema(name="r") + + +def test_geometry_requires_srs_and_definition(): + # `label` deliberately omitted from required set — SWE Common 3 + # inherits it from AbstractDataComponent as optional. + base = dict(label="G", definition="http://example.org/g", + srs="http://www.opengis.net/def/crs/EPSG/0/4326") + for missing in ("definition", "srs"): + kwargs = {k: v for k, v in base.items() if k != missing} + with pytest.raises(ValidationError): + GeometrySchema(**kwargs) + + +def test_geometry_label_is_optional(): + g = GeometrySchema(definition="http://example.org/g", + srs="http://www.opengis.net/def/crs/EPSG/0/4326") + assert g.label is None + + +# --- B.2 discriminator routing --------------------------------------------- + +DISCRIMINATOR_CASES = [ + ("Boolean", + {"type": "Boolean", "label": "B", "definition": "http://example.org/b"}, + BooleanSchema), + ("Count", + {"type": "Count", "label": "C", "definition": "http://example.org/c"}, + CountSchema), + ("Quantity", + {"type": "Quantity", "label": "Q", "definition": "http://example.org/q", + "uom": {"code": "m"}}, + QuantitySchema), + ("Time", + {"type": "Time", "label": "T", "definition": "http://example.org/t", + "uom": {"href": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"}}, + TimeSchema), + ("Category", + {"type": "Category", "label": "Cat", "definition": "http://example.org/cat"}, + CategorySchema), + ("Text", + {"type": "Text", "label": "Tx", "definition": "http://example.org/tx"}, + TextSchema), + ("CountRange", + {"type": "CountRange", "label": "CR", "definition": "http://example.org/cr", + "uom": {"code": "1"}}, + CountRangeSchema), + ("QuantityRange", + {"type": "QuantityRange", "label": "QR", + "definition": "http://example.org/qr", "uom": {"code": "m"}}, + QuantityRangeSchema), + ("TimeRange", + {"type": "TimeRange", "label": "TR", "definition": "http://example.org/tr", + "uom": {"href": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"}}, + TimeRangeSchema), + ("CategoryRange", + {"type": "CategoryRange", "label": "CatR", + "definition": "http://example.org/catr"}, + CategoryRangeSchema), + ("DataRecord", + {"type": "DataRecord", "fields": [_quantity_field("a")]}, + DataRecordSchema), + ("Vector", + {"type": "Vector", "label": "V", "definition": "http://example.org/v", + "referenceFrame": "http://example.org/frames/ENU", + "coordinates": [_quantity_field("x")]}, + VectorSchema), + ("DataArray", + {"type": "DataArray", + "elementCount": {"type": "Count", "name": "n", "label": "n", + "definition": "http://example.org/n"}, + "elementType": _quantity_field("e"), + "encoding": "JSONEncoding"}, + DataArraySchema), + ("Matrix", + {"type": "Matrix", + "elementCount": {"type": "Count", "name": "n", "label": "n", + "definition": "http://example.org/n"}, + "elementType": [_quantity_field("e")], + "encoding": "JSONEncoding"}, + MatrixSchema), + ("DataChoice", + {"type": "DataChoice", + "choiceValue": {"type": "Category", "name": "pick", "label": "Pick", + "definition": "http://example.org/pick"}, + "items": [_quantity_field("a")]}, + DataChoiceSchema), + ("Geometry", + {"type": "Geometry", "label": "G", "definition": "http://example.org/g", + "srs": "http://www.opengis.net/def/crs/EPSG/0/4326"}, + GeometrySchema), +] + + +@pytest.mark.parametrize("type_literal,payload,expected_cls", + DISCRIMINATOR_CASES, + ids=[c[0] for c in DISCRIMINATOR_CASES]) +def test_anycomponent_discriminator_routes(type_literal, payload, expected_cls): + parsed = ANY_COMPONENT.validate_python(payload) + assert isinstance(parsed, expected_cls) + assert parsed.type == type_literal + + +def test_anycomponent_unknown_type_rejected(): + with pytest.raises(ValidationError): + ANY_COMPONENT.validate_python({"type": "NotAType", "label": "X"}) + + +# --- B.3 alias / snake_case parity ----------------------------------------- + +def test_quantity_axis_id_alias_parity(): + via_alias = QuantitySchema.model_validate({ + "name": "wd", "label": "Wind Direction", + "definition": "http://example.org/wd", + "axisID": "z", "uom": {"code": "deg"}, + }) + via_python = QuantitySchema( + name="wd", label="Wind Direction", + definition="http://example.org/wd", axis_id="z", uom={"code": "deg"}, + ) + assert via_alias.axis_id == "z" == via_python.axis_id + assert "axisID" in via_alias.model_dump(by_alias=True, exclude_none=True) + + +def test_vector_referenceframe_alias_parity(): + payload = { + "label": "V", "definition": "http://example.org/v", + "referenceFrame": "http://example.org/frames/ENU", + "coordinates": [_quantity_field("x")], + } + v = VectorSchema.model_validate(payload) + assert v.reference_frame == "http://example.org/frames/ENU" + dumped = v.model_dump(by_alias=True, exclude_none=True) + assert "referenceFrame" in dumped and "reference_frame" not in dumped + + +def test_swe_datastream_obsformat_recordschema_alias_parity(): + fixture = json.loads((FIXTURES_DIR / "fake_weather_schema_swejson.json").read_text()) + parsed_camel = SWEDatastreamRecordSchema.model_validate(fixture) + parsed_snake = SWEDatastreamRecordSchema( + obs_format=fixture["obsFormat"], + record_schema=fixture["recordSchema"], + ) + assert parsed_camel.obs_format == parsed_snake.obs_format + assert parsed_camel.record_schema.name == parsed_snake.record_schema.name + + +# --- B.4 round-trip fidelity ----------------------------------------------- + +@pytest.mark.parametrize("fixture_name,model_cls", [ + ("fake_weather_schema_swejson.json", SWEDatastreamRecordSchema), + ("fake_weather_schema_omjson.json", OMJSONDatastreamRecordSchema), +]) +def test_fixture_round_trip_stable(fixture_name, model_cls): + raw = json.loads((FIXTURES_DIR / fixture_name).read_text()) + first = model_cls.model_validate(raw) + first_dump = first.model_dump(mode="json", by_alias=True, exclude_none=True) + second = model_cls.model_validate(first_dump) + second_dump = second.model_dump(mode="json", by_alias=True, exclude_none=True) + assert first_dump == second_dump + + +def test_anycomponent_round_trip_through_typeadapter(): + # Stable-dump: parse → dump → reparse → dump, second dump matches first. + # We don't compare against the input dict because pydantic adds explicit + # default values (updatable=False / optional=False) to the dump. + payload = _quantity_field("temperature") + first = ANY_COMPONENT.validate_python(payload) + first_dump = ANY_COMPONENT.dump_python(first, mode="json", by_alias=True, + exclude_none=True) + second = ANY_COMPONENT.validate_python(first_dump) + second_dump = ANY_COMPONENT.dump_python(second, mode="json", by_alias=True, + exclude_none=True) + assert first_dump == second_dump + for k, v in payload.items(): + assert first_dump[k] == v + + +# --- B.5 Vector.coordinates element-type restriction ----------------------- + +def test_vector_rejects_boolean_in_coordinates(): + with pytest.raises(ValidationError): + VectorSchema.model_validate({ + "label": "V", "definition": "http://example.org/v", + "referenceFrame": "http://example.org/frames/ENU", + "coordinates": [{ + "type": "Boolean", "name": "flag", "label": "F", + "definition": "http://example.org/f", + }], + }) + + +def test_vector_rejects_record_in_coordinates(): + with pytest.raises(ValidationError): + VectorSchema.model_validate({ + "label": "V", "definition": "http://example.org/v", + "referenceFrame": "http://example.org/frames/ENU", + "coordinates": [{ + "type": "DataRecord", "name": "inner", + "fields": [_quantity_field("a")], + }], + }) + + +def test_vector_accepts_quantity_in_coordinates(): + VectorSchema.model_validate({ + "label": "V", "definition": "http://example.org/v", + "referenceFrame": "http://example.org/frames/ENU", + "coordinates": [_quantity_field("x")], + }) + + +def test_vector_label_is_optional_per_swe_common3(): + # SWE Common 3 Vector inherits AbstractDataComponent.label as optional; + # OSH's SensorLocation datastream emits a labelless Vector. A required + # `label` here would break SWE+JSON schema discovery for any datastream + # carrying a Vector — see the discover_datastreams cascade. + v = VectorSchema.model_validate({ + "type": "Vector", + "name": "location", + "definition": "http://www.opengis.net/def/property/OGC/0/SensorLocation", + "referenceFrame": "http://www.opengis.net/def/crs/EPSG/0/4979", + "coordinates": [_quantity_field("x")], + }) + assert v.label is None + + +def test_swe_datastream_schema_parses_osh_sensor_location_shape(): + # End-to-end shape mirroring `GET /datastreams/{id}/schema` for OSH's + # built-in `sensorLocation` output (CS API SWE+JSON form). + payload = { + "obsFormat": "application/swe+json", + "recordSchema": { + "type": "DataRecord", + "name": "sensorLocation", + "id": "SENSOR_LOCATION", + "label": "Sensor Location", + "fields": [ + { + "type": "Time", + "name": "time", + "definition": "http://www.opengis.net/def/property/OGC/0/SamplingTime", + "label": "Sampling Time", + "referenceFrame": "http://www.opengis.net/def/trs/BIPM/0/UTC", + "uom": {"href": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"}, + }, + { + "type": "Vector", + "name": "location", + "definition": "http://www.opengis.net/def/property/OGC/0/SensorLocation", + "referenceFrame": "http://www.opengis.net/def/crs/EPSG/0/4979", + "localFrame": "#REF_FRAME_LOCAL", + "coordinates": [ + {"type": "Quantity", "name": "lat", "label": "Geodetic Latitude", + "definition": "http://sensorml.com/ont/swe/property/GeodeticLatitude", + "axisID": "Lat", "uom": {"code": "deg"}}, + {"type": "Quantity", "name": "lon", "label": "Longitude", + "definition": "http://sensorml.com/ont/swe/property/Longitude", + "axisID": "Lon", "uom": {"code": "deg"}}, + {"type": "Quantity", "name": "alt", "label": "Ellipsoidal Height", + "definition": "http://sensorml.com/ont/swe/property/HeightAboveEllipsoid", + "axisID": "h", "uom": {"code": "m"}}, + ], + }, + ], + }, + } + sw = SWEDatastreamRecordSchema.from_swejson_dict(payload) + vec = sw.record_schema.fields[1] + assert vec.type == "Vector" + assert vec.label is None + assert vec.reference_frame == "http://www.opengis.net/def/crs/EPSG/0/4979" + assert [c.name for c in vec.coordinates] == ["lat", "lon", "alt"] + + +# --- B.6 DataRecord.fields minItems: 1 ------------------------------------- + +def test_datarecord_empty_fields_rejected(): + with pytest.raises(ValidationError): + DataRecordSchema(name="r", fields=[]) \ No newline at end of file diff --git a/tests/test_swe_flatbuffers.py b/tests/test_swe_flatbuffers.py new file mode 100644 index 0000000..c96ce85 --- /dev/null +++ b/tests/test_swe_flatbuffers.py @@ -0,0 +1,88 @@ +# ============================================================================= +# Copyright (c) 2026 Georobotix Innovative Research +# Date: 2026/5/19 +# Author: Ian Patterson +# Contact Email: ian.patterson@georobotix.us +# ============================================================================= + +"""Tests for the ``application/swe+flatbuffers`` placeholder codec. + +The codec is currently blocked by an upstream `flatc --python` +limitation (no vector-of-union support); we test that the SDK still +parses/round-trips schemas naming this format, and that the +codec raises a clear `NotImplementedError` instead of failing silently. +""" +from __future__ import annotations + +import pytest + +from oshconnect import ( + DataRecordSchema, QuantitySchema, SWEFlatBuffersCodec, + SWEFlatBuffersDatastreamRecordSchema, TimeSchema, +) +from oshconnect.api_utils import UCUMCode, URI + + +def _minimal_record() -> DataRecordSchema: + return DataRecordSchema( + name='r', fields=[ + TimeSchema(name='time', label='Time', + definition='http://www.opengis.net/def/property/OGC/0/SamplingTime', + uom=URI(href='http://www.opengis.net/def/uom/ISO-8601/0/Gregorian')), + QuantitySchema(name='x', label='X', + definition='http://example.org/x', + uom=UCUMCode(code='m', label='m')), + ], + ) + + +def test_schema_round_trips_via_any_datastream_record_schema(): + """SDK can still parse + serialize a swe+flatbuffers schema even though + no codec is wired — discovery / persistence aren't blocked by the codec + being unimplemented.""" + from oshconnect.resource_datamodels import DatastreamResource + + payload = { + "id": "ds-fb", + "name": "fb-stream", + "validTime": ["2026-01-01T00:00:00Z", "2099-01-01T00:00:00Z"], + "schema": { + "obsFormat": "application/swe+flatbuffers", + "recordSchema": _minimal_record().model_dump(by_alias=True, exclude_none=True), + }, + "formats": ["application/swe+flatbuffers"], + } + ds = DatastreamResource.model_validate(payload, by_alias=True) + assert isinstance(ds.record_schema, SWEFlatBuffersDatastreamRecordSchema) + + +def test_encode_raises_notimplemented_with_helpful_message(): + schema = SWEFlatBuffersDatastreamRecordSchema(record_schema=_minimal_record()) + codec = SWEFlatBuffersCodec(schema) + with pytest.raises(NotImplementedError, match="vector.*union"): + codec.encode({"time": "2026-01-01T00:00:00Z", "x": 1.0}) + + +def test_decode_raises_notimplemented_with_helpful_message(): + schema = SWEFlatBuffersDatastreamRecordSchema(record_schema=_minimal_record()) + codec = SWEFlatBuffersCodec(schema) + with pytest.raises(NotImplementedError, match="vector.*union"): + codec.decode(b"\x00\x00\x00\x00") + + +def test_pick_schema_format_picks_flatbuffers_when_present(): + """Format picker should advertise swe+flatbuffers even though the codec + is stubbed — so consumers can still receive and parse the schema; only + encode/decode is blocked. swe+flatbuffers wins over swe+binary when both + are listed (mirrors the proto preference).""" + from oshconnect.resources.system import System + obs_fmt, parser = System._pick_datastream_schema_format([ + "application/swe+flatbuffers", "application/swe+binary", + ]) + assert obs_fmt == "application/swe+flatbuffers" + assert parser.__func__ is SWEFlatBuffersDatastreamRecordSchema.from_sweflatbuffers_dict.__func__ + + +def test_codec_rejects_non_schema_input(): + with pytest.raises(TypeError, match="SWEFlatBuffersDatastreamRecordSchema"): + SWEFlatBuffersCodec(object()) \ No newline at end of file diff --git a/tests/test_swe_name_validation.py b/tests/test_swe_name_validation.py deleted file mode 100644 index a0c3cf0..0000000 --- a/tests/test_swe_name_validation.py +++ /dev/null @@ -1,394 +0,0 @@ -# ============================================================================= -# Copyright (c) 2026 Botts Innovative Research Inc. -# Author: Ian Patterson -# Contact Email: ian@botts-inc.com -# ============================================================================= -""" -SWE Common 3 SoftNamedProperty validation: a `name` is required wherever a -component is bound via SoftNamedProperty (DataRecord.fields, DataChoice.items, -Vector.coordinates, DataArray.elementType, Matrix.elementType, and the root -recordSchema/resultSchema of a datastream/controlstream — i.e., -DataStream.elementType). Names must match NameToken: ^[A-Za-z][A-Za-z0-9_\\-]*$. - -A standalone component (not bound) does NOT require a name; per the spec, -`name` is not a property of any data component itself. -""" -from __future__ import annotations - -import json -from pathlib import Path - -import pytest -from pydantic import ValidationError - -from src.oshconnect.schema_datamodels import ( - JSONDatastreamRecordSchema, - JSONCommandSchema, - SWEDatastreamRecordSchema, - SWEJSONCommandSchema, -) -from src.oshconnect.swe_components import ( - BooleanSchema, - CategorySchema, - CountSchema, - DataArraySchema, - DataChoiceSchema, - DataRecordSchema, - MatrixSchema, - QuantitySchema, - TimeSchema, - VectorSchema, -) - -FIXTURES_DIR = Path(__file__).parent / "fixtures" - -VALID_TIME_FIELD = { - "type": "Time", - "name": "time", - "label": "Sampling Time", - "definition": "http://www.opengis.net/def/property/OGC/0/SamplingTime", - "uom": {"href": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"}, -} -VALID_TEMP_FIELD = { - "type": "Quantity", - "name": "temperature", - "label": "Air Temperature", - "definition": "http://mmisw.org/ont/cf/parameter/air_temperature", - "uom": {"code": "Cel"}, -} -INVALID_NAMES = ["", "1bad", "with space", "has:colon", "has/slash", "has.dot"] - - -# --------------------------------------------------------------------------- -# Standalone components do not need a name (positive cases) -# --------------------------------------------------------------------------- - -def test_quantity_standalone_no_name_ok(): - q = QuantitySchema( - label="Air Temperature", - definition="http://example.org/temperature", - uom={"code": "Cel"}, - ) - assert q.name is None - - -def test_vector_standalone_no_name_ok(): - v = VectorSchema( - label="Position", - definition="http://example.org/position", - referenceFrame="http://example.org/frames/ENU", - coordinates=[ - QuantitySchema( - name="x", label="X", definition="http://example.org/x", uom={"code": "m"} - ), - QuantitySchema( - name="y", label="Y", definition="http://example.org/y", uom={"code": "m"} - ), - ], - ) - assert v.name is None - - -def test_existing_swejson_fixture_round_trips(): - raw = json.loads((FIXTURES_DIR / "fake_weather_schema_swejson.json").read_text()) - parsed = SWEDatastreamRecordSchema.model_validate(raw) - re_dumped = parsed.model_dump(mode="json", by_alias=True, exclude_none=True) - assert re_dumped["recordSchema"]["name"] == "weather" - assert {f["name"] for f in re_dumped["recordSchema"]["fields"]} == { - "time", "temperature", "pressure", "windSpeed", "windDirection" - } - - -def test_existing_omjson_fixture_round_trips(): - raw = json.loads((FIXTURES_DIR / "fake_weather_schema_omjson.json").read_text()) - parsed = JSONDatastreamRecordSchema.model_validate(raw) - re_dumped = parsed.model_dump(mode="json", by_alias=True, exclude_none=True) - assert re_dumped["resultSchema"]["name"] == "weather" - - -# --------------------------------------------------------------------------- -# DataRecord.fields[*] requires name (negative cases) -# --------------------------------------------------------------------------- - -def test_record_with_named_fields_ok(): - DataRecordSchema( - name="weather", - fields=[VALID_TIME_FIELD, VALID_TEMP_FIELD], - ) - - -def test_record_field_missing_name_raises(): - with pytest.raises(ValidationError, match="DataRecord.fields"): - DataRecordSchema( - name="weather", - fields=[ - { - "type": "Quantity", - "label": "Air Temperature", - "definition": "http://example.org/temp", - "uom": {"code": "Cel"}, - } - ], - ) - - -@pytest.mark.parametrize("bad_name", INVALID_NAMES) -def test_record_field_invalid_name_raises(bad_name): - with pytest.raises(ValidationError): - DataRecordSchema( - name="weather", - fields=[ - { - "type": "Quantity", - "name": bad_name, - "label": "Air Temperature", - "definition": "http://example.org/temp", - "uom": {"code": "Cel"}, - } - ], - ) - - -# --------------------------------------------------------------------------- -# DataChoice.items[*] requires name -# --------------------------------------------------------------------------- - -def test_choice_items_named_ok(): - DataChoiceSchema( - name="alt", - choiceValue=CategorySchema( - name="picker", - label="Picker", - definition="http://example.org/picker", - value="a", - ), - items=[ - { - "type": "Quantity", - "name": "alt_a", - "label": "Option A", - "definition": "http://example.org/a", - "uom": {"code": "m"}, - } - ], - ) - - -def test_choice_item_missing_name_raises(): - with pytest.raises(ValidationError, match="DataChoice.items"): - DataChoiceSchema( - name="alt", - choiceValue=CategorySchema( - name="picker", - label="Picker", - definition="http://example.org/picker", - value="a", - ), - items=[ - { - "type": "Quantity", - "label": "Option A", - "definition": "http://example.org/a", - "uom": {"code": "m"}, - } - ], - ) - - -# --------------------------------------------------------------------------- -# Vector.coordinates[*] requires name -# --------------------------------------------------------------------------- - -def test_vector_coordinate_missing_name_raises(): - with pytest.raises(ValidationError, match="Vector.coordinates"): - VectorSchema( - label="Position", - definition="http://example.org/position", - referenceFrame="http://example.org/frames/ENU", - coordinates=[ - { - "type": "Quantity", - "label": "X", - "definition": "http://example.org/x", - "uom": {"code": "m"}, - } - ], - ) - - -# --------------------------------------------------------------------------- -# DataArray.elementType requires name -# --------------------------------------------------------------------------- - -def test_dataarray_element_type_missing_name_raises(): - with pytest.raises(ValidationError, match="DataArray.elementType"): - DataArraySchema( - elementCount={"type": "Count", "name": "n", "label": "n", - "definition": "http://example.org/n"}, - elementType={ - "type": "Quantity", - "label": "X", - "definition": "http://example.org/x", - "uom": {"code": "m"}, - }, - encoding="JSONEncoding", - ) - - -# --------------------------------------------------------------------------- -# Matrix.elementType[*] requires name -# --------------------------------------------------------------------------- - -def test_matrix_element_type_missing_name_raises(): - with pytest.raises(ValidationError, match="Matrix.elementType"): - MatrixSchema( - elementCount={"type": "Count", "name": "n", "label": "n", - "definition": "http://example.org/n"}, - elementType=[ - { - "type": "Quantity", - "label": "X", - "definition": "http://example.org/x", - "uom": {"code": "m"}, - } - ], - encoding="JSONEncoding", - ) - - -# --------------------------------------------------------------------------- -# Datastream/Controlstream wrappers: root requires name -# --------------------------------------------------------------------------- - -def test_swe_datastream_root_requires_name(): - with pytest.raises(ValidationError, match="SWEDatastreamRecordSchema.recordSchema"): - SWEDatastreamRecordSchema.model_validate({ - "obsFormat": "application/swe+json", - "recordSchema": { - "type": "DataRecord", - "definition": "urn:osh:data:weather", - "fields": [VALID_TIME_FIELD], - }, - }) - - -def test_swe_datastream_root_invalid_name_pattern_raises(): - with pytest.raises(ValidationError, match="NameToken"): - SWEDatastreamRecordSchema.model_validate({ - "obsFormat": "application/swe+json", - "recordSchema": { - "type": "DataRecord", - "name": "1bad-leading-digit", - "definition": "urn:osh:data:weather", - "fields": [VALID_TIME_FIELD], - }, - }) - - -def test_json_datastream_optional_when_no_schemas_present(): - # Per CS API Part 2 §16.1.4, JSON form may use resultLink instead of - # inline schemas, so neither resultSchema nor parametersSchema is required. - JSONDatastreamRecordSchema.model_validate({ - "obsFormat": "application/json", - }) - - -def test_json_datastream_result_schema_requires_name_when_present(): - with pytest.raises(ValidationError, match="JSONDatastreamRecordSchema.resultSchema"): - JSONDatastreamRecordSchema.model_validate({ - "obsFormat": "application/json", - "resultSchema": { - "type": "DataRecord", - "definition": "urn:osh:data:weather", - "fields": [VALID_TIME_FIELD], - }, - }) - - -def test_swe_command_schema_root_requires_name(): - with pytest.raises(ValidationError, match="SWEJSONCommandSchema.recordSchema"): - SWEJSONCommandSchema.model_validate({ - "commandFormat": "application/swe+json", - "encoding": {"type": "JSONEncoding"}, - "recordSchema": { - "type": "DataRecord", - "definition": "urn:osh:control:cmd", - "fields": [VALID_TIME_FIELD], - }, - }) - - -def test_json_command_schema_params_requires_name(): - with pytest.raises(ValidationError, match="JSONCommandSchema.parametersSchema"): - JSONCommandSchema.model_validate({ - "commandFormat": "application/json", - "parametersSchema": { - "type": "DataRecord", - "definition": "urn:osh:control:params", - "fields": [VALID_TIME_FIELD], - }, - }) - - -# --------------------------------------------------------------------------- -# NameToken pattern coverage -# --------------------------------------------------------------------------- - -def test_nested_aggregate_in_record_fields_validated(): - # Aggregate-in-aggregate: a DataRecord inside another DataRecord's fields[]. The - # inner record must itself be named (it's the bound child); its own fields are then - # validated by the inner record's validator independently. - DataRecordSchema( - name="outer", - fields=[ - { - "type": "DataRecord", - "name": "inner", - "fields": [VALID_TIME_FIELD], - } - ], - ) - # Inner record present but unnamed → outer's validator catches it. - with pytest.raises(ValidationError, match="DataRecord.fields"): - DataRecordSchema( - name="outer", - fields=[ - { - "type": "DataRecord", - "fields": [VALID_TIME_FIELD], - } - ], - ) - - -@pytest.mark.parametrize("good_name", ["a", "ab", "wind_speed", "wind-speed", "x1", "X_1-y"]) -def test_valid_name_tokens_accepted(good_name): - DataRecordSchema( - name="root", - fields=[ - { - "type": "Quantity", - "name": good_name, - "label": "X", - "definition": "http://example.org/x", - "uom": {"code": "m"}, - } - ], - ) - - -@pytest.mark.parametrize("bad_name", ["1leading", "with space", "with:colon", "with.dot", "with/slash"]) -def test_invalid_name_tokens_rejected(bad_name): - with pytest.raises(ValidationError, match="NameToken"): - DataRecordSchema( - name="root", - fields=[ - { - "type": "Quantity", - "name": bad_name, - "label": "X", - "definition": "http://example.org/x", - "uom": {"code": "m"}, - } - ], - ) diff --git a/tests/test_swe_protobuf.py b/tests/test_swe_protobuf.py new file mode 100644 index 0000000..b573e7e --- /dev/null +++ b/tests/test_swe_protobuf.py @@ -0,0 +1,390 @@ +# ============================================================================= +# Copyright (c) 2026 Georobotix Innovative Research +# Date: 2026/5/19 +# Author: Ian Patterson +# Contact Email: ian.patterson@georobotix.us +# ============================================================================= + +"""Tests for the ``application/swe+proto`` codec. + +The generated protobuf bindings (`sweCommon3_pb2` and friends) live in the +separate BinaryEncodings project and are not bundled with OSHConnect. +Tests that round-trip real wire bytes are gated on the modules being +importable — set ``PYTHONPATH`` to include the project's +``gen/protobuf`` directory, or symlink it under any importable path. + +Default lookup path: ``$BINARY_ENCODINGS_GEN`` (env var) or +``~/IdeaProjects/BinaryEncodings/gen/protobuf``. Override per-run via +the env var. +""" +from __future__ import annotations + +import importlib +import os +import sys +from pathlib import Path + +import pytest + +from oshconnect import ( + BooleanSchema, CategorySchema, CountSchema, DataRecordSchema, + QuantitySchema, SWEProtobufCodec, SWEProtobufDatastreamRecordSchema, + TextSchema, TimeSchema, +) +from oshconnect.api_utils import UCUMCode, URI +from oshconnect.swe_components import ( # noqa: F401 + DataArraySchema, DataChoiceSchema, VectorSchema, +) + + +def _ensure_pb_path() -> bool: + """Prepend the generated protobuf bindings directory to sys.path.""" + candidate = Path( + os.environ.get( + "BINARY_ENCODINGS_GEN", + os.path.expanduser("~/IdeaProjects/BinaryEncodings/gen/protobuf"), + ) + ) + if (candidate / "sweCommon3_pb2.py").is_file(): + path_str = str(candidate) + if path_str not in sys.path: + sys.path.insert(0, path_str) + return True + return False + + +_HAS_PB = _ensure_pb_path() + + +pytestmark = pytest.mark.skipif( + not _HAS_PB, + reason="Generated SWE Common 3 protobuf bindings not found; " + "set BINARY_ENCODINGS_GEN or generate via " + "`make protobuf PROTO_LANG=python` in the BinaryEncodings repo.", +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _scalar_record() -> DataRecordSchema: + """A 5-scalar record covering Time/Quantity/Count/Boolean/Text.""" + return DataRecordSchema( + name='weather', label='Weather', + definition='http://example.org/weather', + fields=[ + TimeSchema(name='time', label='Time', + definition='http://www.opengis.net/def/property/OGC/0/SamplingTime', + uom=URI(href='http://www.opengis.net/def/uom/ISO-8601/0/Gregorian')), + QuantitySchema(name='temp', label='Temperature', + definition='http://example.org/temp', + uom=UCUMCode(code='Cel', label='Celsius')), + CountSchema(name='samples', label='Samples', + definition='http://example.org/samples', + uom=UCUMCode(code='1', label='dimensionless')), + BooleanSchema(name='clear_sky', label='Clear Sky', + definition='http://example.org/clearsky'), + TextSchema(name='note', label='Note', + definition='http://example.org/note'), + ], + ) + + +# --------------------------------------------------------------------------- +# Encoding markers +# --------------------------------------------------------------------------- + + +def test_schema_carries_protobuf_encoding_marker(): + """The default `record_encoding` should be a ProtobufEncoding marker.""" + from oshconnect import ProtobufEncoding + schema = SWEProtobufDatastreamRecordSchema(record_schema=_scalar_record()) + assert isinstance(schema.record_encoding, ProtobufEncoding) + assert schema.obs_format == "application/swe+proto" + + +def test_schema_dispatches_via_any_datastream_record_schema(): + """Round-trip the protobuf record schema through DatastreamResource — + the discriminated union has to route the literal `swe+proto` to + `SWEProtobufDatastreamRecordSchema`.""" + from oshconnect.resource_datamodels import DatastreamResource + + payload = { + "id": "ds-proto", + "name": "proto-stream", + "validTime": ["2026-01-01T00:00:00Z", "2099-01-01T00:00:00Z"], + "schema": { + "obsFormat": "application/swe+proto", + "recordSchema": _scalar_record().model_dump(by_alias=True, exclude_none=True), + }, + "formats": ["application/swe+proto"], + } + ds = DatastreamResource.model_validate(payload, by_alias=True) + assert isinstance(ds.record_schema, SWEProtobufDatastreamRecordSchema) + + +# --------------------------------------------------------------------------- +# Scalar round-trips +# --------------------------------------------------------------------------- + + +def test_round_trip_all_scalars(): + schema = SWEProtobufDatastreamRecordSchema(record_schema=_scalar_record()) + codec = SWEProtobufCodec(schema) + value = { + 'time': '2026-05-19T19:21:15.807Z', + 'temp': 23.5, + 'samples': 42, + 'clear_sky': True, + 'note': 'sunny', + } + wire = codec.encode(value) + assert isinstance(wire, bytes) + assert len(wire) > 0 + assert codec.decode(wire) == value + + +def test_time_accepts_numeric_epoch(): + """`TimeSchema` is wire-permissive: epoch seconds (numeric) or ISO 8601 + string both serialize; the round-trip preserves whichever shape went in.""" + schema = SWEProtobufDatastreamRecordSchema( + record_schema=DataRecordSchema( + name='r', fields=[ + TimeSchema(name='t', label='T', + definition='http://www.opengis.net/def/property/OGC/0/SamplingTime', + uom=URI(href='http://www.opengis.net/def/uom/ISO-8601/0/Gregorian')), + ], + ) + ) + codec = SWEProtobufCodec(schema) + assert codec.decode(codec.encode({'t': 1_779_218_475.807}))['t'] == pytest.approx(1_779_218_475.807) + assert codec.decode(codec.encode({'t': '2026-05-19T19:21:15Z'}))['t'] == '2026-05-19T19:21:15Z' + + +def test_category_round_trip(): + rec = DataRecordSchema( + name='r', fields=[ + CategorySchema(name='state', label='State', + definition='http://example.org/state', + code_space='http://example.org/codes'), + ], + ) + codec = SWEProtobufCodec(SWEProtobufDatastreamRecordSchema(record_schema=rec)) + assert codec.decode(codec.encode({'state': 'on'}))['state'] == 'on' + + +# --------------------------------------------------------------------------- +# Composite types +# --------------------------------------------------------------------------- + + +def test_nested_data_record_round_trip(): + """A DataRecord-in-a-DataRecord should preserve field names across both + layers — the schema-aware decoder pairs proto fields by name, not order.""" + inner = DataRecordSchema( + name='inner', label='Inner', + definition='http://example.org/inner', + fields=[ + QuantitySchema(name='lat', label='Lat', + definition='http://example.org/lat', + uom=UCUMCode(code='deg', label='deg')), + QuantitySchema(name='lon', label='Lon', + definition='http://example.org/lon', + uom=UCUMCode(code='deg', label='deg')), + ], + ) + outer = DataRecordSchema( + name='outer', label='Outer', + definition='http://example.org/outer', + fields=[ + TimeSchema(name='time', label='Time', + definition='http://www.opengis.net/def/property/OGC/0/SamplingTime', + uom=URI(href='http://www.opengis.net/def/uom/ISO-8601/0/Gregorian')), + inner, + ], + ) + codec = SWEProtobufCodec(SWEProtobufDatastreamRecordSchema(record_schema=outer)) + value = {'time': '2026-05-19T00:00:00Z', + 'inner': {'lat': 12.5, 'lon': -42.0}} + assert codec.decode(codec.encode(value)) == value + + +def test_vector_round_trip(): + schema = DataRecordSchema( + name='r', fields=[ + VectorSchema( + name='pos', label='Position', + definition='http://example.org/pos', + reference_frame='http://example.org/frame', + coordinates=[ + QuantitySchema(name='x', label='X', + definition='http://example.org/x', + uom=UCUMCode(code='m', label='m')), + QuantitySchema(name='y', label='Y', + definition='http://example.org/y', + uom=UCUMCode(code='m', label='m')), + QuantitySchema(name='z', label='Z', + definition='http://example.org/z', + uom=UCUMCode(code='m', label='m')), + ], + ), + ], + ) + codec = SWEProtobufCodec(SWEProtobufDatastreamRecordSchema(record_schema=schema)) + value = {'pos': [1.0, 2.0, 3.0]} + out = codec.decode(codec.encode(value)) + assert out == value + + +def test_data_array_round_trip_with_heterogeneous_values(): + """Real round-trip test for DataArray. Wire format mirrors + OSH's BinaryDataWriter: tightly-packed scalars in EncodedValues.inline_data, + with the BinaryEncoding declared inline. + + This is the canary against the pre-fix bug where the encoder silently + dropped all but the first element and the decoder returned [v0]*n. + """ + rec = DataRecordSchema( + name='r', fields=[ + DataArraySchema( + name='samples', label='Samples', + definition='http://example.org/samples', + element_count={'value': 3}, + element_type=QuantitySchema( + name='x', label='X', + definition='http://example.org/x', + uom=UCUMCode(code='m', label='m')), + ), + ], + ) + codec = SWEProtobufCodec(SWEProtobufDatastreamRecordSchema(record_schema=rec)) + value = {'samples': [1.0, 2.0, 3.0]} + assert codec.decode(codec.encode(value)) == value + + +def test_data_array_of_counts_round_trip(): + """Default dataType for Count is signedInt (4 bytes BE), matching OSH.""" + rec = DataRecordSchema( + name='r', fields=[ + DataArraySchema( + name='ids', label='IDs', + definition='http://example.org/ids', + element_count={'value': 4}, + element_type=CountSchema( + name='id', label='ID', + definition='http://example.org/id', + uom=UCUMCode(code='1', label='dimensionless')), + ), + ], + ) + codec = SWEProtobufCodec(SWEProtobufDatastreamRecordSchema(record_schema=rec)) + value = {'ids': [7, 11, 13, 17]} + assert codec.decode(codec.encode(value)) == value + + +def test_data_array_of_records_raises_clear_error(): + """Arrays of records are valid SWE Common 3 but not yet wired in the + Python codec. Raise rather than silently producing wrong bytes.""" + inner = DataRecordSchema( + name='inner', label='Inner', + definition='http://example.org/inner', + fields=[ + QuantitySchema(name='x', label='X', + definition='http://example.org/x', + uom=UCUMCode(code='m', label='m')), + ], + ) + rec = DataRecordSchema( + name='r', fields=[ + DataArraySchema( + name='samples', label='Samples', + definition='http://example.org/samples', + element_count={'value': 2}, + element_type=inner, + ), + ], + ) + codec = SWEProtobufCodec(SWEProtobufDatastreamRecordSchema(record_schema=rec)) + with pytest.raises(TypeError, match="DataArray.element_type"): + codec.encode({'samples': [{'x': 1.0}, {'x': 2.0}]}) + + +def test_picker_prefers_proto_over_flatbuffers(): + """When both encodings are advertised, swe+proto wins because the + flatbuffers codec is currently a stub. This guards against a regression + where the picker silently routes traffic to the broken codec.""" + from oshconnect.resources.system import System + obs_fmt, parser = System._pick_datastream_schema_format([ + "application/swe+flatbuffers", "application/swe+proto", + ]) + assert obs_fmt == "application/swe+proto" + assert parser.__func__ is SWEProtobufDatastreamRecordSchema.from_sweproto_dict.__func__ + + +def test_missing_field_raises_keyerror(): + rec = _scalar_record() + codec = SWEProtobufCodec(SWEProtobufDatastreamRecordSchema(record_schema=rec)) + with pytest.raises(KeyError, match="missing from value mapping"): + codec.encode({'time': '2026-01-01T00:00:00Z'}) # other fields absent + + +# --------------------------------------------------------------------------- +# Wiring through Datastream +# --------------------------------------------------------------------------- + + +def test_datastream_insert_routes_through_protobuf_codec(): + """`Datastream.insert(...)` dispatches via `_encode_for_wire`, which must + pick the protobuf codec when the schema's obsFormat is swe+proto.""" + from oshconnect.resource_datamodels import DatastreamResource + from oshconnect.resources.datastream import Datastream + + class _StubNode: + def register_streamable(self, _s): pass + def get_mqtt_client(self): return None + + payload = { + "id": "ds-proto", + "name": "proto", + "validTime": ["2026-01-01T00:00:00Z", "2099-01-01T00:00:00Z"], + "schema": { + "obsFormat": "application/swe+proto", + "recordSchema": _scalar_record().model_dump(by_alias=True, exclude_none=True), + }, + "formats": ["application/swe+proto"], + } + ds_resource = DatastreamResource.model_validate(payload, by_alias=True) + ds = Datastream(parent_node=_StubNode(), datastream_resource=ds_resource) + captured: list[bytes] = [] + ds._topic = "t" + ds._publish_mqtt = lambda topic, p: captured.append(p) + + value = {'time': '2026-01-01T00:00:00Z', 'temp': 7.0, + 'samples': 1, 'clear_sky': False, 'note': 'x'} + ds.insert(value) + assert len(captured) == 1 + # Decode it back to confirm wire fidelity end-to-end + assert ds.decode_observation(captured[0]) == value + + +def test_pick_schema_format_picks_protobuf_when_present(): + from oshconnect.resources.system import System + obs_fmt, parser = System._pick_datastream_schema_format([ + "application/om+json", "application/swe+proto", + ]) + assert obs_fmt == "application/swe+proto" + assert parser.__func__ is SWEProtobufDatastreamRecordSchema.from_sweproto_dict.__func__ + + +def test_pick_schema_format_prefers_swe_json_over_proto(): + """swe+json wins when both are advertised — protobuf is the fallback when + JSON isn't available, mirroring the swe+binary fallback for video.""" + from oshconnect.resources.system import System + from oshconnect import SWEDatastreamRecordSchema + obs_fmt, parser = System._pick_datastream_schema_format([ + "application/swe+json", "application/swe+proto", + ]) + assert obs_fmt == "application/swe+json" + assert parser.__func__ is SWEDatastreamRecordSchema.from_swejson_dict.__func__ \ No newline at end of file diff --git a/tests/test_swe_schema_validation.py b/tests/test_swe_schema_validation.py deleted file mode 100644 index 738f01f..0000000 --- a/tests/test_swe_schema_validation.py +++ /dev/null @@ -1,371 +0,0 @@ -# ============================================================================= -# Copyright (c) 2026 Botts Innovative Research Inc. -# Author: Ian Patterson -# Contact Email: ian@botts-inc.com -# ============================================================================= -""" -SWE Common 3 schema-conformance tests beyond the SoftNamedProperty `name` rule: - -1. Spec `required` arrays per leaf component type (Quantity needs uom, Vector - needs referenceFrame, etc.) — guard against accidental Field(...) → Field(None) - regressions. -2. Discriminator routing: AnyComponent.model_validate dispatches by `type` to - the correct concrete class, and rejects unknown types. -3. Alias / field-name parity: both camelCase wire-format and snake_case Python - names parse to identical models. -4. Round-trip fidelity: parse → dump(by_alias, exclude_none) → re-parse, deep equal. -5. Vector.coordinates element-type restriction (Count/Quantity/Time only). -6. DataRecord.fields minItems: 1 (per DataRecord.json). -""" -from __future__ import annotations - -import json -from pathlib import Path - -import pytest -from pydantic import TypeAdapter, ValidationError - -from src.oshconnect.schema_datamodels import ( - JSONDatastreamRecordSchema, - SWEDatastreamRecordSchema, -) -from src.oshconnect.swe_components import ( - AnyComponent, - BooleanSchema, - CategoryRangeSchema, - CategorySchema, - CountRangeSchema, - CountSchema, - DataArraySchema, - DataChoiceSchema, - DataRecordSchema, - GeometrySchema, - MatrixSchema, - QuantityRangeSchema, - QuantitySchema, - TextSchema, - TimeRangeSchema, - TimeSchema, - VectorSchema, -) - -FIXTURES_DIR = Path(__file__).parent / "fixtures" -ANY_COMPONENT = TypeAdapter(AnyComponent) - - -def _quantity_field(name: str = "x") -> dict: - return { - "type": "Quantity", - "name": name, - "label": "X", - "definition": "http://example.org/x", - "uom": {"code": "m"}, - } - - -# --------------------------------------------------------------------------- -# 1. Spec `required` arrays per leaf component type -# --------------------------------------------------------------------------- -# Per JSON schemas at: -# https://github.com/opengeospatial/ogcapi-connected-systems/tree/master/swecommon/schemas/json -# Required arrays: -# Quantity: [type, definition, label, uom] -# Boolean: [type, definition, label] -# Text: [type, definition, label] (inherited Boolean shape) -# Vector: [type, definition, referenceFrame, label, coordinates] -# DataRecord:[type, fields] -# Geometry: [type, srs, definition, label] - - -def test_quantity_requires_uom(): - with pytest.raises(ValidationError, match="uom"): - QuantitySchema(label="X", definition="http://example.org/x") - - -def test_quantity_requires_label(): - with pytest.raises(ValidationError, match="label"): - QuantitySchema(definition="http://example.org/x", uom={"code": "m"}) - - -def test_quantity_requires_definition(): - with pytest.raises(ValidationError, match="definition"): - QuantitySchema(label="X", uom={"code": "m"}) - - -def test_boolean_requires_label_and_definition(): - with pytest.raises(ValidationError, match="label"): - BooleanSchema(definition="http://example.org/b") - with pytest.raises(ValidationError, match="definition"): - BooleanSchema(label="X") - - -def test_text_requires_label_and_definition(): - with pytest.raises(ValidationError, match="label"): - TextSchema(definition="http://example.org/t") - with pytest.raises(ValidationError, match="definition"): - TextSchema(label="X") - - -def test_vector_requires_label_definition_referenceframe_coordinates(): - base = dict( - label="V", - definition="http://example.org/v", - referenceFrame="http://example.org/frames/ENU", - coordinates=[ - QuantitySchema(name="x", label="X", - definition="http://example.org/x", uom={"code": "m"}), - ], - ) - for missing in ("label", "definition", "referenceFrame", "coordinates"): - kwargs = {k: v for k, v in base.items() if k != missing} - with pytest.raises(ValidationError): - VectorSchema(**kwargs) - - -def test_datarecord_requires_fields(): - with pytest.raises(ValidationError, match="fields"): - DataRecordSchema(name="r") - - -def test_geometry_requires_srs_definition_label(): - base = dict( - label="G", - definition="http://example.org/g", - srs="http://www.opengis.net/def/crs/EPSG/0/4326", - ) - for missing in ("label", "definition", "srs"): - kwargs = {k: v for k, v in base.items() if k != missing} - with pytest.raises(ValidationError): - GeometrySchema(**kwargs) - - -# --------------------------------------------------------------------------- -# 2. Discriminator routing -# --------------------------------------------------------------------------- - -DISCRIMINATOR_CASES = [ - # (type literal, minimal-valid dict, expected pydantic class) - ("Boolean", - {"type": "Boolean", "label": "B", "definition": "http://example.org/b"}, - BooleanSchema), - ("Count", - {"type": "Count", "label": "C", "definition": "http://example.org/c"}, - CountSchema), - ("Quantity", - {"type": "Quantity", "label": "Q", "definition": "http://example.org/q", - "uom": {"code": "m"}}, - QuantitySchema), - ("Time", - {"type": "Time", "label": "T", "definition": "http://example.org/t", - "uom": {"href": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"}}, - TimeSchema), - ("Category", - {"type": "Category", "label": "Cat", "definition": "http://example.org/cat"}, - CategorySchema), - ("Text", - {"type": "Text", "label": "Tx", "definition": "http://example.org/tx"}, - TextSchema), - ("CountRange", - {"type": "CountRange", "label": "CR", "definition": "http://example.org/cr", - "uom": {"code": "1"}}, - CountRangeSchema), - ("QuantityRange", - {"type": "QuantityRange", "label": "QR", "definition": "http://example.org/qr", - "uom": {"code": "m"}}, - QuantityRangeSchema), - ("TimeRange", - {"type": "TimeRange", "label": "TR", "definition": "http://example.org/tr", - "uom": {"href": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"}}, - TimeRangeSchema), - ("CategoryRange", - {"type": "CategoryRange", "label": "CatR", - "definition": "http://example.org/catr"}, - CategoryRangeSchema), - ("DataRecord", - {"type": "DataRecord", "fields": [_quantity_field("a")]}, - DataRecordSchema), - ("Vector", - {"type": "Vector", "label": "V", "definition": "http://example.org/v", - "referenceFrame": "http://example.org/frames/ENU", - "coordinates": [_quantity_field("x")]}, - VectorSchema), - ("DataArray", - {"type": "DataArray", - "elementCount": {"type": "Count", "name": "n", "label": "n", - "definition": "http://example.org/n"}, - "elementType": _quantity_field("e"), - "encoding": "JSONEncoding"}, - DataArraySchema), - ("Matrix", - {"type": "Matrix", - "elementCount": {"type": "Count", "name": "n", "label": "n", - "definition": "http://example.org/n"}, - "elementType": [_quantity_field("e")], - "encoding": "JSONEncoding"}, - MatrixSchema), - ("DataChoice", - {"type": "DataChoice", - "choiceValue": {"type": "Category", "name": "pick", "label": "Pick", - "definition": "http://example.org/pick"}, - "items": [_quantity_field("a")]}, - DataChoiceSchema), - ("Geometry", - {"type": "Geometry", "label": "G", "definition": "http://example.org/g", - "srs": "http://www.opengis.net/def/crs/EPSG/0/4326"}, - GeometrySchema), -] - - -@pytest.mark.parametrize( - "type_literal,payload,expected_cls", - DISCRIMINATOR_CASES, - ids=[c[0] for c in DISCRIMINATOR_CASES], -) -def test_anycomponent_discriminator_routes(type_literal, payload, expected_cls): - parsed = ANY_COMPONENT.validate_python(payload) - assert isinstance(parsed, expected_cls) - assert parsed.type == type_literal - - -def test_anycomponent_unknown_type_rejected(): - with pytest.raises(ValidationError): - ANY_COMPONENT.validate_python({"type": "NotAType", "label": "X"}) - - -# --------------------------------------------------------------------------- -# 3. Alias / field-name parity -# --------------------------------------------------------------------------- -# OSH wire format is camelCase; our pydantic fields are snake_case with alias= -# entries. Confirm both inputs produce equivalent models, and dumping by_alias -# yields the camelCase form. - - -def test_quantity_axis_id_alias_parity(): - via_alias = QuantitySchema.model_validate({ - "name": "wd", - "label": "Wind Direction", - "definition": "http://example.org/wd", - "axisID": "z", - "uom": {"code": "deg"}, - }) - via_python = QuantitySchema( - name="wd", label="Wind Direction", - definition="http://example.org/wd", axis_id="z", uom={"code": "deg"}, - ) - assert via_alias.axis_id == "z" == via_python.axis_id - assert "axisID" in via_alias.model_dump(by_alias=True, exclude_none=True) - - -def test_vector_referenceframe_alias_parity(): - payload = { - "label": "V", "definition": "http://example.org/v", - "referenceFrame": "http://example.org/frames/ENU", - "coordinates": [_quantity_field("x")], - } - v = VectorSchema.model_validate(payload) - assert v.reference_frame == "http://example.org/frames/ENU" - dumped = v.model_dump(by_alias=True, exclude_none=True) - assert "referenceFrame" in dumped - assert "reference_frame" not in dumped - - -def test_swe_datastream_obsformat_recordschema_alias_parity(): - fixture = json.loads((FIXTURES_DIR / "fake_weather_schema_swejson.json").read_text()) - parsed_camel = SWEDatastreamRecordSchema.model_validate(fixture) - parsed_snake = SWEDatastreamRecordSchema( - obs_format=fixture["obsFormat"], - record_schema=fixture["recordSchema"], - ) - assert parsed_camel.obs_format == parsed_snake.obs_format - assert parsed_camel.record_schema.name == parsed_snake.record_schema.name - - -# --------------------------------------------------------------------------- -# 4. Round-trip fidelity -# --------------------------------------------------------------------------- -# Strongest single guard against serializer regressions: load a fixture, -# dump it, re-parse the dump, and confirm the second dump matches the first. - - -@pytest.mark.parametrize( - "fixture_name,model_cls", - [ - ("fake_weather_schema_swejson.json", SWEDatastreamRecordSchema), - ("fake_weather_schema_omjson.json", JSONDatastreamRecordSchema), - ], -) -def test_fixture_round_trip_stable(fixture_name, model_cls): - raw = json.loads((FIXTURES_DIR / fixture_name).read_text()) - first = model_cls.model_validate(raw) - first_dump = first.model_dump(mode="json", by_alias=True, exclude_none=True) - second = model_cls.model_validate(first_dump) - second_dump = second.model_dump(mode="json", by_alias=True, exclude_none=True) - assert first_dump == second_dump - - -def test_anycomponent_round_trip_through_typeadapter(): - # Stable-dump: parse → dump → reparse → dump, second dump matches first. - # (We don't compare against the input dict because pydantic adds explicit - # default values like updatable=False / optional=False to the dump.) - payload = _quantity_field("temperature") - first = ANY_COMPONENT.validate_python(payload) - first_dump = ANY_COMPONENT.dump_python(first, mode="json", by_alias=True, - exclude_none=True) - second = ANY_COMPONENT.validate_python(first_dump) - second_dump = ANY_COMPONENT.dump_python(second, mode="json", by_alias=True, - exclude_none=True) - assert first_dump == second_dump - # Sanity: input keys are all preserved in the dump. - for k, v in payload.items(): - assert first_dump[k] == v - - -# --------------------------------------------------------------------------- -# 5. Vector.coordinates element-type restriction -# --------------------------------------------------------------------------- -# Vector.json: coordinates items oneOf [Count, Quantity, Time]. - - -def test_vector_rejects_boolean_in_coordinates(): - with pytest.raises(ValidationError): - VectorSchema.model_validate({ - "label": "V", "definition": "http://example.org/v", - "referenceFrame": "http://example.org/frames/ENU", - "coordinates": [{ - "type": "Boolean", "name": "flag", "label": "F", - "definition": "http://example.org/f", - }], - }) - - -def test_vector_rejects_record_in_coordinates(): - with pytest.raises(ValidationError): - VectorSchema.model_validate({ - "label": "V", "definition": "http://example.org/v", - "referenceFrame": "http://example.org/frames/ENU", - "coordinates": [{ - "type": "DataRecord", "name": "inner", - "fields": [_quantity_field("a")], - }], - }) - - -def test_vector_accepts_count_quantity_time_in_coordinates(): - VectorSchema.model_validate({ - "label": "V", "definition": "http://example.org/v", - "referenceFrame": "http://example.org/frames/ENU", - "coordinates": [ - {"type": "Quantity", "name": "x", "label": "X", - "definition": "http://example.org/x", "uom": {"code": "m"}}, - ], - }) - - -# --------------------------------------------------------------------------- -# 6. DataRecord.fields minItems: 1 -# --------------------------------------------------------------------------- - - -def test_datarecord_empty_fields_rejected(): - with pytest.raises(ValidationError): - DataRecordSchema(name="r", fields=[]) \ No newline at end of file diff --git a/tests/test_time_management.py b/tests/test_time_management.py new file mode 100644 index 0000000..b16cf16 --- /dev/null +++ b/tests/test_time_management.py @@ -0,0 +1,22 @@ +"""TimePeriod / TimeInstant primitives from oshconnect.timemanagement.""" +from oshconnect import TimeInstant, TimePeriod + + +def test_time_period_with_iso_strings_resolves_to_time_instants(): + tp = TimePeriod(start="2024-06-18T15:46:32Z", end="2024-06-18T20:00:00Z") + assert isinstance(tp.start, TimeInstant) + assert isinstance(tp.end, TimeInstant) + assert tp.start.epoch_time == TimeInstant.from_string("2024-06-18T15:46:32Z").epoch_time + assert tp.end.epoch_time == TimeInstant.from_string("2024-06-18T20:00:00Z").epoch_time + + +def test_time_period_now_sentinel_preserved_at_start(): + tp = TimePeriod(start="now", end="2099-06-18T20:00:00Z") + assert tp.start == "now" + assert tp.end.epoch_time == TimeInstant.from_string("2099-06-18T20:00:00Z").epoch_time + + +def test_time_period_now_sentinel_preserved_at_end(): + tp = TimePeriod(start="2024-06-18T20:00:00Z", end="now") + assert tp.start.epoch_time == TimeInstant.from_string("2024-06-18T20:00:00Z").epoch_time + assert tp.end == "now" \ No newline at end of file diff --git a/uv.lock b/uv.lock index 5c55e6b..3ff947e 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,18 @@ version = 1 revision = 3 requires-python = ">=3.12, <4.0" +[[package]] +name = "accessible-pygments" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" } +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 = "aiohappyeyeballs" version = "2.6.1" @@ -13,7 +25,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.12.15" +version = "3.13.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -24,42 +36,76 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", size = 7823716, upload-time = "2025-07-29T05:52:32.215Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/63/97/77cb2450d9b35f517d6cf506256bf4f5bda3f93a66b4ad64ba7fc917899c/aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7", size = 702333, upload-time = "2025-07-29T05:50:46.507Z" }, - { url = "https://files.pythonhosted.org/packages/83/6d/0544e6b08b748682c30b9f65640d006e51f90763b41d7c546693bc22900d/aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444", size = 476948, upload-time = "2025-07-29T05:50:48.067Z" }, - { url = "https://files.pythonhosted.org/packages/3a/1d/c8c40e611e5094330284b1aea8a4b02ca0858f8458614fa35754cab42b9c/aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d", size = 469787, upload-time = "2025-07-29T05:50:49.669Z" }, - { url = "https://files.pythonhosted.org/packages/38/7d/b76438e70319796bfff717f325d97ce2e9310f752a267bfdf5192ac6082b/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c", size = 1716590, upload-time = "2025-07-29T05:50:51.368Z" }, - { url = "https://files.pythonhosted.org/packages/79/b1/60370d70cdf8b269ee1444b390cbd72ce514f0d1cd1a715821c784d272c9/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0", size = 1699241, upload-time = "2025-07-29T05:50:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/a3/2b/4968a7b8792437ebc12186db31523f541943e99bda8f30335c482bea6879/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab", size = 1754335, upload-time = "2025-07-29T05:50:55.394Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c1/49524ed553f9a0bec1a11fac09e790f49ff669bcd14164f9fab608831c4d/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb", size = 1800491, upload-time = "2025-07-29T05:50:57.202Z" }, - { url = "https://files.pythonhosted.org/packages/de/5e/3bf5acea47a96a28c121b167f5ef659cf71208b19e52a88cdfa5c37f1fcc/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545", size = 1719929, upload-time = "2025-07-29T05:50:59.192Z" }, - { url = "https://files.pythonhosted.org/packages/39/94/8ae30b806835bcd1cba799ba35347dee6961a11bd507db634516210e91d8/aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c", size = 1635733, upload-time = "2025-07-29T05:51:01.394Z" }, - { url = "https://files.pythonhosted.org/packages/7a/46/06cdef71dd03acd9da7f51ab3a9107318aee12ad38d273f654e4f981583a/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd", size = 1696790, upload-time = "2025-07-29T05:51:03.657Z" }, - { url = "https://files.pythonhosted.org/packages/02/90/6b4cfaaf92ed98d0ec4d173e78b99b4b1a7551250be8937d9d67ecb356b4/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f", size = 1718245, upload-time = "2025-07-29T05:51:05.911Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e6/2593751670fa06f080a846f37f112cbe6f873ba510d070136a6ed46117c6/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d", size = 1658899, upload-time = "2025-07-29T05:51:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/8f/28/c15bacbdb8b8eb5bf39b10680d129ea7410b859e379b03190f02fa104ffd/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519", size = 1738459, upload-time = "2025-07-29T05:51:09.56Z" }, - { url = "https://files.pythonhosted.org/packages/00/de/c269cbc4faa01fb10f143b1670633a8ddd5b2e1ffd0548f7aa49cb5c70e2/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea", size = 1766434, upload-time = "2025-07-29T05:51:11.423Z" }, - { url = "https://files.pythonhosted.org/packages/52/b0/4ff3abd81aa7d929b27d2e1403722a65fc87b763e3a97b3a2a494bfc63bc/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3", size = 1726045, upload-time = "2025-07-29T05:51:13.689Z" }, - { url = "https://files.pythonhosted.org/packages/71/16/949225a6a2dd6efcbd855fbd90cf476052e648fb011aa538e3b15b89a57a/aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1", size = 423591, upload-time = "2025-07-29T05:51:15.452Z" }, - { url = "https://files.pythonhosted.org/packages/2b/d8/fa65d2a349fe938b76d309db1a56a75c4fb8cc7b17a398b698488a939903/aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34", size = 450266, upload-time = "2025-07-29T05:51:17.239Z" }, - { url = "https://files.pythonhosted.org/packages/f2/33/918091abcf102e39d15aba2476ad9e7bd35ddb190dcdd43a854000d3da0d/aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315", size = 696741, upload-time = "2025-07-29T05:51:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2a/7495a81e39a998e400f3ecdd44a62107254803d1681d9189be5c2e4530cd/aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd", size = 474407, upload-time = "2025-07-29T05:51:21.165Z" }, - { url = "https://files.pythonhosted.org/packages/49/fc/a9576ab4be2dcbd0f73ee8675d16c707cfc12d5ee80ccf4015ba543480c9/aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4", size = 466703, upload-time = "2025-07-29T05:51:22.948Z" }, - { url = "https://files.pythonhosted.org/packages/09/2f/d4bcc8448cf536b2b54eed48f19682031ad182faa3a3fee54ebe5b156387/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7", size = 1705532, upload-time = "2025-07-29T05:51:25.211Z" }, - { url = "https://files.pythonhosted.org/packages/f1/f3/59406396083f8b489261e3c011aa8aee9df360a96ac8fa5c2e7e1b8f0466/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d", size = 1686794, upload-time = "2025-07-29T05:51:27.145Z" }, - { url = "https://files.pythonhosted.org/packages/dc/71/164d194993a8d114ee5656c3b7ae9c12ceee7040d076bf7b32fb98a8c5c6/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b", size = 1738865, upload-time = "2025-07-29T05:51:29.366Z" }, - { url = "https://files.pythonhosted.org/packages/1c/00/d198461b699188a93ead39cb458554d9f0f69879b95078dce416d3209b54/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d", size = 1788238, upload-time = "2025-07-29T05:51:31.285Z" }, - { url = "https://files.pythonhosted.org/packages/85/b8/9e7175e1fa0ac8e56baa83bf3c214823ce250d0028955dfb23f43d5e61fd/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d", size = 1710566, upload-time = "2025-07-29T05:51:33.219Z" }, - { url = "https://files.pythonhosted.org/packages/59/e4/16a8eac9df39b48ae102ec030fa9f726d3570732e46ba0c592aeeb507b93/aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645", size = 1624270, upload-time = "2025-07-29T05:51:35.195Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f8/cd84dee7b6ace0740908fd0af170f9fab50c2a41ccbc3806aabcb1050141/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461", size = 1677294, upload-time = "2025-07-29T05:51:37.215Z" }, - { url = "https://files.pythonhosted.org/packages/ce/42/d0f1f85e50d401eccd12bf85c46ba84f947a84839c8a1c2c5f6e8ab1eb50/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9", size = 1708958, upload-time = "2025-07-29T05:51:39.328Z" }, - { url = "https://files.pythonhosted.org/packages/d5/6b/f6fa6c5790fb602538483aa5a1b86fcbad66244997e5230d88f9412ef24c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d", size = 1651553, upload-time = "2025-07-29T05:51:41.356Z" }, - { url = "https://files.pythonhosted.org/packages/04/36/a6d36ad545fa12e61d11d1932eef273928b0495e6a576eb2af04297fdd3c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693", size = 1727688, upload-time = "2025-07-29T05:51:43.452Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c8/f195e5e06608a97a4e52c5d41c7927301bf757a8e8bb5bbf8cef6c314961/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64", size = 1761157, upload-time = "2025-07-29T05:51:45.643Z" }, - { url = "https://files.pythonhosted.org/packages/05/6a/ea199e61b67f25ba688d3ce93f63b49b0a4e3b3d380f03971b4646412fc6/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51", size = 1710050, upload-time = "2025-07-29T05:51:48.203Z" }, - { url = "https://files.pythonhosted.org/packages/b4/2e/ffeb7f6256b33635c29dbed29a22a723ff2dd7401fff42ea60cf2060abfb/aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0", size = 422647, upload-time = "2025-07-29T05:51:50.718Z" }, - { url = "https://files.pythonhosted.org/packages/1b/8e/78ee35774201f38d5e1ba079c9958f7629b1fd079459aea9467441dbfbf5/aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84", size = 449067, upload-time = "2025-07-29T05:51:52.549Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, + { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, + { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, + { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, + { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, + { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, + { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, + { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, + { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, + { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, + { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" }, + { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" }, + { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" }, + { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" }, + { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" }, + { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" }, + { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, + { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, + { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, + { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, + { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, + { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, + { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, + { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, + { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, + { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, + { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, + { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, + { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, + { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, + { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, + { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, + { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, + { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, ] [[package]] @@ -77,11 +123,11 @@ wheels = [ [[package]] name = "alabaster" -version = "0.7.16" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/3e/13dd8e5ed9094e734ac430b5d0eb4f2bb001708a8b7856cbf8e084e001ba/alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65", size = 23776, upload-time = "2024-01-10T00:56:10.189Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511, upload-time = "2024-01-10T00:56:08.388Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, ] [[package]] @@ -95,77 +141,139 @@ wheels = [ [[package]] name = "attrs" -version = "25.3.0" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "av" +version = "17.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/f0/8c8dca97ae0cf00e8e2a53bb5cb9aca5fd484f585ef3e9b412200aff3ebd/av-17.0.1.tar.gz", hash = "sha256:fbcbd4aa43bca6a8691816283112d1659a27f407bbeb66d1397023691339f5d4", size = 4411938, upload-time = "2026-04-18T17:12:34.29Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, + { url = "https://files.pythonhosted.org/packages/4c/82/e7007dcef7bd2d2c377e2e85977701384f42d19fc808c2ccb3a99eaf58f2/av-17.0.1-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:987f4f46ceae4da6c614dcbd2b8149be9dbf680c3bb7a6841c58af9cff4d9230", size = 23238802, upload-time = "2026-04-18T17:11:51.166Z" }, + { url = "https://files.pythonhosted.org/packages/6b/aa/858b09a08ea6f83f91be44b5a5adad13ae8d9ac8b80fda27e73c24bfb160/av-17.0.1-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:d97f54e55b18a74912f479c1978aadd1341d38d892dee95bb5c2f2dccfa72f32", size = 18709338, upload-time = "2026-04-18T17:11:53.286Z" }, + { url = "https://files.pythonhosted.org/packages/a8/8b/8de3fd21c4b0b74d44337421abeab0e71462337fb6a28fff888e0c356cbd/av-17.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e6eee84afa48d0e9321047cd3e4facd44b401493f6bdc753e2e1d1e7c9e6d13e", size = 34007351, upload-time = "2026-04-18T17:11:56.116Z" }, + { url = "https://files.pythonhosted.org/packages/02/28/167b291356c2cc315a2d62a95b0ceace72b5b0bf547de30b89313110f032/av-17.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c58c71bffd9383908c85695ac61d3184c668accb04a5bd1b262e0fb8d09f60a5", size = 36345295, upload-time = "2026-04-18T17:11:59.125Z" }, + { url = "https://files.pythonhosted.org/packages/04/fa/aae56f2ff2c204c408641e1120f5ca5ce9c3390cf5362245c6f1158704b5/av-17.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:42d6745d30a410ec9b22aef79a52a7ab5a001eb8f5adfd952946606a30983318", size = 35183754, upload-time = "2026-04-18T17:12:01.697Z" }, + { url = "https://files.pythonhosted.org/packages/ba/bd/776046f27093aef80155a204ca7d82a887ae4ee72ba4ef8411b46ea7898c/av-17.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3ed6bcd7021fe55832f95b8ef78dd01a4cb21faf3cd71f1e1bf4f20bf100b278", size = 37430809, upload-time = "2026-04-18T17:12:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d5/3261bd2c6b7f6c0aa8379fc970d1ecf496330990b992ad28607785074268/av-17.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:9af524e8632a54032e361d6b88895bd3e7c6212ca560de60f5ccc525323c764c", size = 28889649, upload-time = "2026-04-18T17:12:07.04Z" }, + { url = "https://files.pythonhosted.org/packages/98/39/381104e427a0c7231d2ec0d25d538d58fc20fc0458846b95860d3ef8073b/av-17.0.1-cp311-abi3-win_arm64.whl", hash = "sha256:50e58a473d65ea29b645e45c9fd8518a6783737135683ecc40571a91592bdfe4", size = 21918412, upload-time = "2026-04-18T17:12:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8c/bb1498f031abb6157b30b7fc2379359176953821b6ba59fbd89dbb56f61f/av-17.0.1-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:1d33871742d1e71562db3c8e752cacc5a62766d7efc3ae408bff1c3e26ebb46e", size = 23484157, upload-time = "2026-04-18T17:12:11.67Z" }, + { url = "https://files.pythonhosted.org/packages/1a/58/dedaef187b797243cd5762722e376c69c5ad95ab23db44127f09afc2cd66/av-17.0.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:1229e879f4b6431bc00f69d7f8891fe9a683b0a6e0e009e6c98eb7e449f0383d", size = 18920872, upload-time = "2026-04-18T17:12:14.826Z" }, + { url = "https://files.pythonhosted.org/packages/9b/26/5c550231651d6285e6a5c4f6f4a0e67459bfe2b622a7c9352be8cca8c819/av-17.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4744837f4116964280bcc72285e3cdd51361e98a696205aadd924203440ef511", size = 37471077, upload-time = "2026-04-18T17:12:17.349Z" }, + { url = "https://files.pythonhosted.org/packages/59/e4/9807b89a9d775c6f015677996c48bce48aaff70b5d95885adf39e59832a2/av-17.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:3d0a7d45d9599bf9df9f8249827113d4f36df1cd6b5356227b997f0552dbc98e", size = 39566981, upload-time = "2026-04-18T17:12:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/5c/72/a22a657abc3de652f5b4f46cbbebdf7cba629752112791b81f05d340991d/av-17.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9acd0b6a6e02af2b37f63d97a03ee2c47936d58e82425c3cd075a95245937c59", size = 38397369, upload-time = "2026-04-18T17:12:22.909Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b2/f4e83e41c1e3c186f34b7df506779d0cd7e40499e2e19519c7ece148cd20/av-17.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3d3a36204cb1f1e7691e6446afa8d6b7097b09946dae732c71c5d05ce09e506e", size = 40582445, upload-time = "2026-04-18T17:12:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/c8/59/8676188b72eed09d48ce6cfaf0f22b0bb9f3cfd74d388ee2b7fdf960536d/av-17.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:b87b98afe971cde123953073bc9c95ab0b7efd2ecc082dd2dbd11f9d9abf190e", size = 29217136, upload-time = "2026-04-18T17:12:29.189Z" }, + { url = "https://files.pythonhosted.org/packages/5f/af/0a6e1d2a845988039f6c197fa7269b5e9abbe17354fb41cc9d75bb260fcb/av-17.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:a87a42c36e29f75e7dff7281944f2a6876a2c8875e225ccbf6c1ae62748b4caa", size = 22072676, upload-time = "2026-04-18T17:12:31.836Z" }, ] [[package]] name = "babel" -version = "2.17.0" +version = "2.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] [[package]] -name = "backrefs" -version = "7.0" +name = "beautifulsoup4" +version = "4.14.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/a7dd63622beef68cc0d3c3c36d472e143dd95443d5ebf14cd1a5b4dfbf11/backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82", size = 7012453, upload-time = "2026-04-28T16:28:04.215Z" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/39/39a31d7eae729ea14ed10c3ccef79371197177b9355a86cb3525709e8502/backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9", size = 380824, upload-time = "2026-04-28T16:27:55.647Z" }, - { url = "https://files.pythonhosted.org/packages/c9/b5/9302644225ba7dfa934a2ff2b9c7bb85701313a90dddb3dfaf693fa5bae2/backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475", size = 392626, upload-time = "2026-04-28T16:27:57.42Z" }, - { url = "https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12", size = 398537, upload-time = "2026-04-28T16:27:58.913Z" }, - { url = "https://files.pythonhosted.org/packages/00/bb/90ba423612b6aa0adccc6b1874bcd4a9b44b660c0c16f346611e00f64ac3/backrefs-7.0-py313-none-any.whl", hash = "sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a", size = 400491, upload-time = "2026-04-28T16:28:00.928Z" }, - { url = "https://files.pythonhosted.org/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-none-any.whl", hash = "sha256:a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9", size = 412349, upload-time = "2026-04-28T16:28:02.412Z" }, + { 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 = "certifi" -version = "2025.1.31" +version = "2026.4.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577, upload-time = "2025-01-31T02:16:47.166Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393, upload-time = "2025-01-31T02:16:45.015Z" }, + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] [[package]] name = "charset-normalizer" -version = "3.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188, upload-time = "2024-12-24T18:12:35.43Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105, upload-time = "2024-12-24T18:10:38.83Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404, upload-time = "2024-12-24T18:10:44.272Z" }, - { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423, upload-time = "2024-12-24T18:10:45.492Z" }, - { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184, upload-time = "2024-12-24T18:10:47.898Z" }, - { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268, upload-time = "2024-12-24T18:10:50.589Z" }, - { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601, upload-time = "2024-12-24T18:10:52.541Z" }, - { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098, upload-time = "2024-12-24T18:10:53.789Z" }, - { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520, upload-time = "2024-12-24T18:10:55.048Z" }, - { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852, upload-time = "2024-12-24T18:10:57.647Z" }, - { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488, upload-time = "2024-12-24T18:10:59.43Z" }, - { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192, upload-time = "2024-12-24T18:11:00.676Z" }, - { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550, upload-time = "2024-12-24T18:11:01.952Z" }, - { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785, upload-time = "2024-12-24T18:11:03.142Z" }, - { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698, upload-time = "2024-12-24T18:11:05.834Z" }, - { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162, upload-time = "2024-12-24T18:11:07.064Z" }, - { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263, upload-time = "2024-12-24T18:11:08.374Z" }, - { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966, upload-time = "2024-12-24T18:11:09.831Z" }, - { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992, upload-time = "2024-12-24T18:11:12.03Z" }, - { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162, upload-time = "2024-12-24T18:11:13.372Z" }, - { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972, upload-time = "2024-12-24T18:11:14.628Z" }, - { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095, upload-time = "2024-12-24T18:11:17.672Z" }, - { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668, upload-time = "2024-12-24T18:11:18.989Z" }, - { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073, upload-time = "2024-12-24T18:11:21.507Z" }, - { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732, upload-time = "2024-12-24T18:11:22.774Z" }, - { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391, upload-time = "2024-12-24T18:11:24.139Z" }, - { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702, upload-time = "2024-12-24T18:11:26.535Z" }, - { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767, upload-time = "2024-12-24T18:12:32.852Z" }, +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] @@ -189,135 +297,267 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + [[package]] name = "docutils" -version = "0.20.1" +version = "0.22.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/53/a5da4f2c5739cf66290fac1431ee52aff6851c7c8ffd8264f13affd7bcdd/docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b", size = 2058365, upload-time = "2023-05-16T23:39:19.748Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/87/f238c0670b94533ac0353a4e2a1a771a0cc73277b88bff23d3ae35a256c1/docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6", size = 572666, upload-time = "2023-05-16T23:39:15.976Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] [[package]] name = "flake8" -version = "7.2.0" +version = "7.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mccabe" }, { name = "pycodestyle" }, { name = "pyflakes" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e7/c4/5842fc9fc94584c455543540af62fd9900faade32511fab650e9891ec225/flake8-7.2.0.tar.gz", hash = "sha256:fa558ae3f6f7dbf2b4f22663e5343b6b6023620461f8d4ff2019ef4b5ee70426", size = 48177, upload-time = "2025-03-29T20:08:39.329Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/5c/0627be4c9976d56b1217cb5187b7504e7fd7d3503f8bfd312a04077bd4f7/flake8-7.2.0-py2.py3-none-any.whl", hash = "sha256:93b92ba5bdb60754a6da14fa3b93a9361fd00a59632ada61fd7b130436c40343", size = 57786, upload-time = "2025-03-29T20:08:37.902Z" }, + { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, ] [[package]] -name = "frozenlist" -version = "1.7.0" +name = "flatbuffers" +version = "25.12.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424, upload-time = "2025-06-09T23:00:42.24Z" }, - { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952, upload-time = "2025-06-09T23:00:43.481Z" }, - { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688, upload-time = "2025-06-09T23:00:44.793Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084, upload-time = "2025-06-09T23:00:46.125Z" }, - { url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524, upload-time = "2025-06-09T23:00:47.73Z" }, - { url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493, upload-time = "2025-06-09T23:00:49.742Z" }, - { url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116, upload-time = "2025-06-09T23:00:51.352Z" }, - { url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557, upload-time = "2025-06-09T23:00:52.855Z" }, - { url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820, upload-time = "2025-06-09T23:00:54.43Z" }, - { url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542, upload-time = "2025-06-09T23:00:56.409Z" }, - { url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350, upload-time = "2025-06-09T23:00:58.468Z" }, - { url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093, upload-time = "2025-06-09T23:01:00.015Z" }, - { url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482, upload-time = "2025-06-09T23:01:01.474Z" }, - { url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590, upload-time = "2025-06-09T23:01:02.961Z" }, - { url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785, upload-time = "2025-06-09T23:01:05.095Z" }, - { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487, upload-time = "2025-06-09T23:01:06.54Z" }, - { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874, upload-time = "2025-06-09T23:01:07.752Z" }, - { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" }, - { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" }, - { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" }, - { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" }, - { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" }, - { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" }, - { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" }, - { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" }, - { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" }, - { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" }, - { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" }, - { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" }, - { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" }, - { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" }, - { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload-time = "2025-06-09T23:01:35.503Z" }, - { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload-time = "2025-06-09T23:01:36.784Z" }, - { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" }, - { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" }, - { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" }, - { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" }, - { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" }, - { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" }, - { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" }, - { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" }, - { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" }, - { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" }, - { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" }, - { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" }, - { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" }, - { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" }, - { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload-time = "2025-06-09T23:02:02.072Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload-time = "2025-06-09T23:02:03.779Z" }, - { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, -] - -[[package]] -name = "ghp-import" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "python-dateutil" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, ] [[package]] -name = "griffelib" -version = "2.0.2" +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/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 = "furo" +version = "2025.12.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } +dependencies = [ + { name = "accessible-pygments" }, + { name = "beautifulsoup4" }, + { name = "pygments" }, + { name = "sphinx" }, + { name = "sphinx-basic-ng" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/20/5f5ad4da6a5a27c80f2ed2ee9aee3f9e36c66e56e21c00fde467b2f8f88f/furo-2025.12.19.tar.gz", hash = "sha256:188d1f942037d8b37cd3985b955839fea62baa1730087dc29d157677c857e2a7", size = 1661473, upload-time = "2025-12-19T17:34:40.889Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, + { 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 = "idna" -version = "3.10" +version = "3.13" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, ] [[package]] name = "imagesize" -version = "1.4.1" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, ] [[package]] name = "iniconfig" -version = "2.1.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "interrogate" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "click" }, + { name = "colorama" }, + { name = "py" }, + { name = "tabulate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/22/74f7fcc96280eea46cf2bcbfa1354ac31de0e60a4be6f7966f12cef20893/interrogate-1.7.0.tar.gz", hash = "sha256:a320d6ec644dfd887cc58247a345054fc4d9f981100c45184470068f4b3719b0", size = 159636, upload-time = "2024-04-07T22:30:46.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl", hash = "sha256:b13ff4dd8403369670e2efe684066de9fcb868ad9d7f2b4095d8112142dc9d12", size = 46982, upload-time = "2024-04-07T22:30:44.277Z" }, ] [[package]] @@ -333,50 +573,78 @@ wheels = [ ] [[package]] -name = "markdown" -version = "3.10.2" +name = "markdown-it-py" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] [[package]] name = "markupsafe" -version = "3.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, - { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, - { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, - { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, - { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, - { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, - { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, - { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, - { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, - { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, - { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, - { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, - { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, - { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, - { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, - { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, - { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, - { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, - { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, - { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, - { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, - { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, - { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, - { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] @@ -389,237 +657,206 @@ wheels = [ ] [[package]] -name = "mergedeep" -version = "1.3.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, -] - -[[package]] -name = "mkdocs" -version = "1.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "ghp-import" }, - { name = "jinja2" }, - { name = "markdown" }, - { name = "markupsafe" }, - { name = "mergedeep" }, - { name = "mkdocs-get-deps" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "pyyaml" }, - { name = "pyyaml-env-tag" }, - { name = "watchdog" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, -] - -[[package]] -name = "mkdocs-autorefs" -version = "1.4.4" +name = "mdit-py-plugins" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown" }, - { name = "markupsafe" }, - { name = "mkdocs" }, + { name = "markdown-it-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/3d/e0e8d9d1cee04f758120915e2b2a3a07eb41f8cf4654b4734788a522bcd1/mdit_py_plugins-0.6.0.tar.gz", hash = "sha256:2436f14a7295837ac9228a36feeabda867c4abc488c8d019ad5c0bda88eee040", size = 56025, upload-time = "2026-05-07T12:20:42.295Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, + { url = "https://files.pythonhosted.org/packages/71/d6/48f5b9e44e2e760855d7b489b1317cd7620e82dcb73197961e5cc1391348/mdit_py_plugins-0.6.0-py3-none-any.whl", hash = "sha256:f7e7a25d8b616fee99cb1e330da73451d11a8061baf39bb9663ab9ce0e005b90", size = 66655, upload-time = "2026-05-07T12:20:41.226Z" }, ] [[package]] -name = "mkdocs-get-deps" -version = "0.2.2" +name = "mdurl" +version = "0.1.2" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mergedeep" }, - { name = "platformdirs" }, - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] -name = "mkdocs-material" -version = "9.7.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "babel" }, - { name = "backrefs" }, - { name = "colorama" }, - { name = "jinja2" }, - { name = "markdown" }, - { name = "mkdocs" }, - { name = "mkdocs-material-extensions" }, - { name = "paginate" }, - { name = "pygments" }, - { name = "pymdown-extensions" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, -] - -[[package]] -name = "mkdocs-material-extensions" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, -] - -[[package]] -name = "mkdocstrings" -version = "1.0.4" +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/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 = "5.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "docutils" }, { name = "jinja2" }, - { name = "markdown" }, - { name = "markupsafe" }, - { name = "mkdocs" }, - { name = "mkdocs-autorefs" }, - { name = "pymdown-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1d/5d/f888d4d3eb31359b327bc9b17a212d6ef03fe0b0682fbb3fc2cb849fb12b/mkdocstrings-1.0.4.tar.gz", hash = "sha256:3969a6515b77db65fd097b53c1b7aa4ae840bd71a2ee62a6a3e89503446d7172", size = 100088, upload-time = "2026-04-15T09:16:53.376Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl", hash = "sha256:63464b4b29053514f32a1dbbf604e52876d5e638111b0c295ab7ed3cac73ca9b", size = 35560, upload-time = "2026-04-15T09:16:51.436Z" }, -] - -[package.optional-dependencies] -python = [ - { name = "mkdocstrings-python" }, -] - -[[package]] -name = "mkdocstrings-python" -version = "2.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "griffelib" }, - { name = "mkdocs-autorefs" }, - { name = "mkdocstrings" }, + { name = "markdown-it-py" }, + { name = "mdit-py-plugins" }, + { name = "pyyaml" }, + { name = "sphinx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/29/33/c225eaf898634bdda489a6766fc35d1683c640bffe0e0acd10646b13536d/mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8", size = 199083, upload-time = "2026-02-20T10:38:36.368Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/fa/7b45eef11b7971f0beb29d27b7bfe0d747d063aa29e170d9edd004733c8a/myst_parser-5.0.0.tar.gz", hash = "sha256:f6f231452c56e8baa662cc352c548158f6a16fcbd6e3800fc594978002b94f3a", size = 98535, upload-time = "2026-01-15T09:08:18.036Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779, upload-time = "2026-02-20T10:38:34.517Z" }, -] - -[[package]] -name = "multidict" -version = "6.6.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/7f/0652e6ed47ab288e3756ea9c0df8b14950781184d4bd7883f4d87dd41245/multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd", size = 101843, upload-time = "2025-08-11T12:08:48.217Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/f6/512ffd8fd8b37fb2680e5ac35d788f1d71bbaf37789d21a820bdc441e565/multidict-6.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0ffb87be160942d56d7b87b0fdf098e81ed565add09eaa1294268c7f3caac4c8", size = 76516, upload-time = "2025-08-11T12:06:53.393Z" }, - { url = "https://files.pythonhosted.org/packages/99/58/45c3e75deb8855c36bd66cc1658007589662ba584dbf423d01df478dd1c5/multidict-6.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d191de6cbab2aff5de6c5723101705fd044b3e4c7cfd587a1929b5028b9714b3", size = 45394, upload-time = "2025-08-11T12:06:54.555Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ca/e8c4472a93a26e4507c0b8e1f0762c0d8a32de1328ef72fd704ef9cc5447/multidict-6.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38a0956dd92d918ad5feff3db8fcb4a5eb7dba114da917e1a88475619781b57b", size = 43591, upload-time = "2025-08-11T12:06:55.672Z" }, - { url = "https://files.pythonhosted.org/packages/05/51/edf414f4df058574a7265034d04c935aa84a89e79ce90fcf4df211f47b16/multidict-6.6.4-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6865f6d3b7900ae020b495d599fcf3765653bc927951c1abb959017f81ae8287", size = 237215, upload-time = "2025-08-11T12:06:57.213Z" }, - { url = "https://files.pythonhosted.org/packages/c8/45/8b3d6dbad8cf3252553cc41abea09ad527b33ce47a5e199072620b296902/multidict-6.6.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a2088c126b6f72db6c9212ad827d0ba088c01d951cee25e758c450da732c138", size = 258299, upload-time = "2025-08-11T12:06:58.946Z" }, - { url = "https://files.pythonhosted.org/packages/3c/e8/8ca2e9a9f5a435fc6db40438a55730a4bf4956b554e487fa1b9ae920f825/multidict-6.6.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0f37bed7319b848097085d7d48116f545985db988e2256b2e6f00563a3416ee6", size = 242357, upload-time = "2025-08-11T12:07:00.301Z" }, - { url = "https://files.pythonhosted.org/packages/0f/84/80c77c99df05a75c28490b2af8f7cba2a12621186e0a8b0865d8e745c104/multidict-6.6.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:01368e3c94032ba6ca0b78e7ccb099643466cf24f8dc8eefcfdc0571d56e58f9", size = 268369, upload-time = "2025-08-11T12:07:01.638Z" }, - { url = "https://files.pythonhosted.org/packages/0d/e9/920bfa46c27b05fb3e1ad85121fd49f441492dca2449c5bcfe42e4565d8a/multidict-6.6.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe323540c255db0bffee79ad7f048c909f2ab0edb87a597e1c17da6a54e493c", size = 269341, upload-time = "2025-08-11T12:07:02.943Z" }, - { url = "https://files.pythonhosted.org/packages/af/65/753a2d8b05daf496f4a9c367fe844e90a1b2cac78e2be2c844200d10cc4c/multidict-6.6.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8eb3025f17b0a4c3cd08cda49acf312a19ad6e8a4edd9dbd591e6506d999402", size = 256100, upload-time = "2025-08-11T12:07:04.564Z" }, - { url = "https://files.pythonhosted.org/packages/09/54/655be13ae324212bf0bc15d665a4e34844f34c206f78801be42f7a0a8aaa/multidict-6.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbc14f0365534d35a06970d6a83478b249752e922d662dc24d489af1aa0d1be7", size = 253584, upload-time = "2025-08-11T12:07:05.914Z" }, - { url = "https://files.pythonhosted.org/packages/5c/74/ab2039ecc05264b5cec73eb018ce417af3ebb384ae9c0e9ed42cb33f8151/multidict-6.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:75aa52fba2d96bf972e85451b99d8e19cc37ce26fd016f6d4aa60da9ab2b005f", size = 251018, upload-time = "2025-08-11T12:07:08.301Z" }, - { url = "https://files.pythonhosted.org/packages/af/0a/ccbb244ac848e56c6427f2392741c06302bbfba49c0042f1eb3c5b606497/multidict-6.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fefd4a815e362d4f011919d97d7b4a1e566f1dde83dc4ad8cfb5b41de1df68d", size = 251477, upload-time = "2025-08-11T12:07:10.248Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b0/0ed49bba775b135937f52fe13922bc64a7eaf0a3ead84a36e8e4e446e096/multidict-6.6.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db9801fe021f59a5b375ab778973127ca0ac52429a26e2fd86aa9508f4d26eb7", size = 263575, upload-time = "2025-08-11T12:07:11.928Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d9/7fb85a85e14de2e44dfb6a24f03c41e2af8697a6df83daddb0e9b7569f73/multidict-6.6.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a650629970fa21ac1fb06ba25dabfc5b8a2054fcbf6ae97c758aa956b8dba802", size = 259649, upload-time = "2025-08-11T12:07:13.244Z" }, - { url = "https://files.pythonhosted.org/packages/03/9e/b3a459bcf9b6e74fa461a5222a10ff9b544cb1cd52fd482fb1b75ecda2a2/multidict-6.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:452ff5da78d4720d7516a3a2abd804957532dd69296cb77319c193e3ffb87e24", size = 251505, upload-time = "2025-08-11T12:07:14.57Z" }, - { url = "https://files.pythonhosted.org/packages/86/a2/8022f78f041dfe6d71e364001a5cf987c30edfc83c8a5fb7a3f0974cff39/multidict-6.6.4-cp312-cp312-win32.whl", hash = "sha256:8c2fcb12136530ed19572bbba61b407f655e3953ba669b96a35036a11a485793", size = 41888, upload-time = "2025-08-11T12:07:15.904Z" }, - { url = "https://files.pythonhosted.org/packages/c7/eb/d88b1780d43a56db2cba24289fa744a9d216c1a8546a0dc3956563fd53ea/multidict-6.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:047d9425860a8c9544fed1b9584f0c8bcd31bcde9568b047c5e567a1025ecd6e", size = 46072, upload-time = "2025-08-11T12:07:17.045Z" }, - { url = "https://files.pythonhosted.org/packages/9f/16/b929320bf5750e2d9d4931835a4c638a19d2494a5b519caaaa7492ebe105/multidict-6.6.4-cp312-cp312-win_arm64.whl", hash = "sha256:14754eb72feaa1e8ae528468f24250dd997b8e2188c3d2f593f9eba259e4b364", size = 43222, upload-time = "2025-08-11T12:07:18.328Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5d/e1db626f64f60008320aab00fbe4f23fc3300d75892a3381275b3d284580/multidict-6.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f46a6e8597f9bd71b31cc708195d42b634c8527fecbcf93febf1052cacc1f16e", size = 75848, upload-time = "2025-08-11T12:07:19.912Z" }, - { url = "https://files.pythonhosted.org/packages/4c/aa/8b6f548d839b6c13887253af4e29c939af22a18591bfb5d0ee6f1931dae8/multidict-6.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:22e38b2bc176c5eb9c0a0e379f9d188ae4cd8b28c0f53b52bce7ab0a9e534657", size = 45060, upload-time = "2025-08-11T12:07:21.163Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c6/f5e97e5d99a729bc2aa58eb3ebfa9f1e56a9b517cc38c60537c81834a73f/multidict-6.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5df8afd26f162da59e218ac0eefaa01b01b2e6cd606cffa46608f699539246da", size = 43269, upload-time = "2025-08-11T12:07:22.392Z" }, - { url = "https://files.pythonhosted.org/packages/dc/31/d54eb0c62516776f36fe67f84a732f97e0b0e12f98d5685bebcc6d396910/multidict-6.6.4-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:49517449b58d043023720aa58e62b2f74ce9b28f740a0b5d33971149553d72aa", size = 237158, upload-time = "2025-08-11T12:07:23.636Z" }, - { url = "https://files.pythonhosted.org/packages/c4/1c/8a10c1c25b23156e63b12165a929d8eb49a6ed769fdbefb06e6f07c1e50d/multidict-6.6.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9408439537c5afdca05edd128a63f56a62680f4b3c234301055d7a2000220f", size = 257076, upload-time = "2025-08-11T12:07:25.049Z" }, - { url = "https://files.pythonhosted.org/packages/ad/86/90e20b5771d6805a119e483fd3d1e8393e745a11511aebca41f0da38c3e2/multidict-6.6.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:87a32d20759dc52a9e850fe1061b6e41ab28e2998d44168a8a341b99ded1dba0", size = 240694, upload-time = "2025-08-11T12:07:26.458Z" }, - { url = "https://files.pythonhosted.org/packages/e7/49/484d3e6b535bc0555b52a0a26ba86e4d8d03fd5587d4936dc59ba7583221/multidict-6.6.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52e3c8d43cdfff587ceedce9deb25e6ae77daba560b626e97a56ddcad3756879", size = 266350, upload-time = "2025-08-11T12:07:27.94Z" }, - { url = "https://files.pythonhosted.org/packages/bf/b4/aa4c5c379b11895083d50021e229e90c408d7d875471cb3abf721e4670d6/multidict-6.6.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ad8850921d3a8d8ff6fbef790e773cecfc260bbfa0566998980d3fa8f520bc4a", size = 267250, upload-time = "2025-08-11T12:07:29.303Z" }, - { url = "https://files.pythonhosted.org/packages/80/e5/5e22c5bf96a64bdd43518b1834c6d95a4922cc2066b7d8e467dae9b6cee6/multidict-6.6.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:497a2954adc25c08daff36f795077f63ad33e13f19bfff7736e72c785391534f", size = 254900, upload-time = "2025-08-11T12:07:30.764Z" }, - { url = "https://files.pythonhosted.org/packages/17/38/58b27fed927c07035abc02befacab42491e7388ca105e087e6e0215ead64/multidict-6.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:024ce601f92d780ca1617ad4be5ac15b501cc2414970ffa2bb2bbc2bd5a68fa5", size = 252355, upload-time = "2025-08-11T12:07:32.205Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a1/dad75d23a90c29c02b5d6f3d7c10ab36c3197613be5d07ec49c7791e186c/multidict-6.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a693fc5ed9bdd1c9e898013e0da4dcc640de7963a371c0bd458e50e046bf6438", size = 250061, upload-time = "2025-08-11T12:07:33.623Z" }, - { url = "https://files.pythonhosted.org/packages/b8/1a/ac2216b61c7f116edab6dc3378cca6c70dc019c9a457ff0d754067c58b20/multidict-6.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:190766dac95aab54cae5b152a56520fd99298f32a1266d66d27fdd1b5ac00f4e", size = 249675, upload-time = "2025-08-11T12:07:34.958Z" }, - { url = "https://files.pythonhosted.org/packages/d4/79/1916af833b800d13883e452e8e0977c065c4ee3ab7a26941fbfdebc11895/multidict-6.6.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:34d8f2a5ffdceab9dcd97c7a016deb2308531d5f0fced2bb0c9e1df45b3363d7", size = 261247, upload-time = "2025-08-11T12:07:36.588Z" }, - { url = "https://files.pythonhosted.org/packages/c5/65/d1f84fe08ac44a5fc7391cbc20a7cedc433ea616b266284413fd86062f8c/multidict-6.6.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:59e8d40ab1f5a8597abcef00d04845155a5693b5da00d2c93dbe88f2050f2812", size = 257960, upload-time = "2025-08-11T12:07:39.735Z" }, - { url = "https://files.pythonhosted.org/packages/13/b5/29ec78057d377b195ac2c5248c773703a6b602e132a763e20ec0457e7440/multidict-6.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:467fe64138cfac771f0e949b938c2e1ada2b5af22f39692aa9258715e9ea613a", size = 250078, upload-time = "2025-08-11T12:07:41.525Z" }, - { url = "https://files.pythonhosted.org/packages/c4/0e/7e79d38f70a872cae32e29b0d77024bef7834b0afb406ddae6558d9e2414/multidict-6.6.4-cp313-cp313-win32.whl", hash = "sha256:14616a30fe6d0a48d0a48d1a633ab3b8bec4cf293aac65f32ed116f620adfd69", size = 41708, upload-time = "2025-08-11T12:07:43.405Z" }, - { url = "https://files.pythonhosted.org/packages/9d/34/746696dffff742e97cd6a23da953e55d0ea51fa601fa2ff387b3edcfaa2c/multidict-6.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:40cd05eaeb39e2bc8939451f033e57feaa2ac99e07dbca8afe2be450a4a3b6cf", size = 45912, upload-time = "2025-08-11T12:07:45.082Z" }, - { url = "https://files.pythonhosted.org/packages/c7/87/3bac136181e271e29170d8d71929cdeddeb77f3e8b6a0c08da3a8e9da114/multidict-6.6.4-cp313-cp313-win_arm64.whl", hash = "sha256:f6eb37d511bfae9e13e82cb4d1af36b91150466f24d9b2b8a9785816deb16605", size = 43076, upload-time = "2025-08-11T12:07:46.746Z" }, - { url = "https://files.pythonhosted.org/packages/64/94/0a8e63e36c049b571c9ae41ee301ada29c3fee9643d9c2548d7d558a1d99/multidict-6.6.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6c84378acd4f37d1b507dfa0d459b449e2321b3ba5f2338f9b085cf7a7ba95eb", size = 82812, upload-time = "2025-08-11T12:07:48.402Z" }, - { url = "https://files.pythonhosted.org/packages/25/1a/be8e369dfcd260d2070a67e65dd3990dd635cbd735b98da31e00ea84cd4e/multidict-6.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0e0558693063c75f3d952abf645c78f3c5dfdd825a41d8c4d8156fc0b0da6e7e", size = 48313, upload-time = "2025-08-11T12:07:49.679Z" }, - { url = "https://files.pythonhosted.org/packages/26/5a/dd4ade298674b2f9a7b06a32c94ffbc0497354df8285f27317c66433ce3b/multidict-6.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3f8e2384cb83ebd23fd07e9eada8ba64afc4c759cd94817433ab8c81ee4b403f", size = 46777, upload-time = "2025-08-11T12:07:51.318Z" }, - { url = "https://files.pythonhosted.org/packages/89/db/98aa28bc7e071bfba611ac2ae803c24e96dd3a452b4118c587d3d872c64c/multidict-6.6.4-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f996b87b420995a9174b2a7c1a8daf7db4750be6848b03eb5e639674f7963773", size = 229321, upload-time = "2025-08-11T12:07:52.965Z" }, - { url = "https://files.pythonhosted.org/packages/c7/bc/01ddda2a73dd9d167bd85d0e8ef4293836a8f82b786c63fb1a429bc3e678/multidict-6.6.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc356250cffd6e78416cf5b40dc6a74f1edf3be8e834cf8862d9ed5265cf9b0e", size = 249954, upload-time = "2025-08-11T12:07:54.423Z" }, - { url = "https://files.pythonhosted.org/packages/06/78/6b7c0f020f9aa0acf66d0ab4eb9f08375bac9a50ff5e3edb1c4ccd59eafc/multidict-6.6.4-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dadf95aa862714ea468a49ad1e09fe00fcc9ec67d122f6596a8d40caf6cec7d0", size = 228612, upload-time = "2025-08-11T12:07:55.914Z" }, - { url = "https://files.pythonhosted.org/packages/00/44/3faa416f89b2d5d76e9d447296a81521e1c832ad6e40b92f990697b43192/multidict-6.6.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dd57515bebffd8ebd714d101d4c434063322e4fe24042e90ced41f18b6d3395", size = 257528, upload-time = "2025-08-11T12:07:57.371Z" }, - { url = "https://files.pythonhosted.org/packages/05/5f/77c03b89af0fcb16f018f668207768191fb9dcfb5e3361a5e706a11db2c9/multidict-6.6.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:967af5f238ebc2eb1da4e77af5492219fbd9b4b812347da39a7b5f5c72c0fa45", size = 256329, upload-time = "2025-08-11T12:07:58.844Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e9/ed750a2a9afb4f8dc6f13dc5b67b514832101b95714f1211cd42e0aafc26/multidict-6.6.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a4c6875c37aae9794308ec43e3530e4aa0d36579ce38d89979bbf89582002bb", size = 247928, upload-time = "2025-08-11T12:08:01.037Z" }, - { url = "https://files.pythonhosted.org/packages/1f/b5/e0571bc13cda277db7e6e8a532791d4403dacc9850006cb66d2556e649c0/multidict-6.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f683a551e92bdb7fac545b9c6f9fa2aebdeefa61d607510b3533286fcab67f5", size = 245228, upload-time = "2025-08-11T12:08:02.96Z" }, - { url = "https://files.pythonhosted.org/packages/f3/a3/69a84b0eccb9824491f06368f5b86e72e4af54c3067c37c39099b6687109/multidict-6.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3ba5aaf600edaf2a868a391779f7a85d93bed147854925f34edd24cc70a3e141", size = 235869, upload-time = "2025-08-11T12:08:04.746Z" }, - { url = "https://files.pythonhosted.org/packages/a9/9d/28802e8f9121a6a0804fa009debf4e753d0a59969ea9f70be5f5fdfcb18f/multidict-6.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:580b643b7fd2c295d83cad90d78419081f53fd532d1f1eb67ceb7060f61cff0d", size = 243446, upload-time = "2025-08-11T12:08:06.332Z" }, - { url = "https://files.pythonhosted.org/packages/38/ea/6c98add069b4878c1d66428a5f5149ddb6d32b1f9836a826ac764b9940be/multidict-6.6.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:37b7187197da6af3ee0b044dbc9625afd0c885f2800815b228a0e70f9a7f473d", size = 252299, upload-time = "2025-08-11T12:08:07.931Z" }, - { url = "https://files.pythonhosted.org/packages/3a/09/8fe02d204473e14c0af3affd50af9078839dfca1742f025cca765435d6b4/multidict-6.6.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e1b93790ed0bc26feb72e2f08299691ceb6da5e9e14a0d13cc74f1869af327a0", size = 246926, upload-time = "2025-08-11T12:08:09.467Z" }, - { url = "https://files.pythonhosted.org/packages/37/3d/7b1e10d774a6df5175ecd3c92bff069e77bed9ec2a927fdd4ff5fe182f67/multidict-6.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a506a77ddee1efcca81ecbeae27ade3e09cdf21a8ae854d766c2bb4f14053f92", size = 243383, upload-time = "2025-08-11T12:08:10.981Z" }, - { url = "https://files.pythonhosted.org/packages/50/b0/a6fae46071b645ae98786ab738447de1ef53742eaad949f27e960864bb49/multidict-6.6.4-cp313-cp313t-win32.whl", hash = "sha256:f93b2b2279883d1d0a9e1bd01f312d6fc315c5e4c1f09e112e4736e2f650bc4e", size = 47775, upload-time = "2025-08-11T12:08:12.439Z" }, - { url = "https://files.pythonhosted.org/packages/b2/0a/2436550b1520091af0600dff547913cb2d66fbac27a8c33bc1b1bccd8d98/multidict-6.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:6d46a180acdf6e87cc41dc15d8f5c2986e1e8739dc25dbb7dac826731ef381a4", size = 53100, upload-time = "2025-08-11T12:08:13.823Z" }, - { url = "https://files.pythonhosted.org/packages/97/ea/43ac51faff934086db9c072a94d327d71b7d8b40cd5dcb47311330929ef0/multidict-6.6.4-cp313-cp313t-win_arm64.whl", hash = "sha256:756989334015e3335d087a27331659820d53ba432befdef6a718398b0a8493ad", size = 45501, upload-time = "2025-08-11T12:08:15.173Z" }, - { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/686789b9145413f1a61878c407210e41bfdb097976864e0913078b24098c/myst_parser-5.0.0-py3-none-any.whl", hash = "sha256:ab31e516024918296e169139072b81592336f2fef55b8986aa31c9f04b5f7211", size = 84533, upload-time = "2026-01-15T09:08:16.788Z" }, ] [[package]] name = "numpy" -version = "2.2.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/78/31103410a57bc2c2b93a3597340a8119588571f6a4539067546cb9a0bfac/numpy-2.2.4.tar.gz", hash = "sha256:9ba03692a45d3eef66559efe1d1096c4b9b75c0986b5dff5530c378fb8331d4f", size = 20270701, upload-time = "2025-03-16T18:27:00.648Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/30/182db21d4f2a95904cec1a6f779479ea1ac07c0647f064dea454ec650c42/numpy-2.2.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a7b9084668aa0f64e64bd00d27ba5146ef1c3a8835f3bd912e7a9e01326804c4", size = 20947156, upload-time = "2025-03-16T18:09:51.975Z" }, - { url = "https://files.pythonhosted.org/packages/24/6d/9483566acfbda6c62c6bc74b6e981c777229d2af93c8eb2469b26ac1b7bc/numpy-2.2.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dbe512c511956b893d2dacd007d955a3f03d555ae05cfa3ff1c1ff6df8851854", size = 14133092, upload-time = "2025-03-16T18:10:16.329Z" }, - { url = "https://files.pythonhosted.org/packages/27/f6/dba8a258acbf9d2bed2525cdcbb9493ef9bae5199d7a9cb92ee7e9b2aea6/numpy-2.2.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:bb649f8b207ab07caebba230d851b579a3c8711a851d29efe15008e31bb4de24", size = 5163515, upload-time = "2025-03-16T18:10:26.19Z" }, - { url = "https://files.pythonhosted.org/packages/62/30/82116199d1c249446723c68f2c9da40d7f062551036f50b8c4caa42ae252/numpy-2.2.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:f34dc300df798742b3d06515aa2a0aee20941c13579d7a2f2e10af01ae4901ee", size = 6696558, upload-time = "2025-03-16T18:10:38.996Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b2/54122b3c6df5df3e87582b2e9430f1bdb63af4023c739ba300164c9ae503/numpy-2.2.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3f7ac96b16955634e223b579a3e5798df59007ca43e8d451a0e6a50f6bfdfba", size = 14084742, upload-time = "2025-03-16T18:11:02.76Z" }, - { url = "https://files.pythonhosted.org/packages/02/e2/e2cbb8d634151aab9528ef7b8bab52ee4ab10e076509285602c2a3a686e0/numpy-2.2.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f92084defa704deadd4e0a5ab1dc52d8ac9e8a8ef617f3fbb853e79b0ea3592", size = 16134051, upload-time = "2025-03-16T18:11:32.767Z" }, - { url = "https://files.pythonhosted.org/packages/8e/21/efd47800e4affc993e8be50c1b768de038363dd88865920439ef7b422c60/numpy-2.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4e84a6283b36632e2a5b56e121961f6542ab886bc9e12f8f9818b3c266bfbb", size = 15578972, upload-time = "2025-03-16T18:11:59.877Z" }, - { url = "https://files.pythonhosted.org/packages/04/1e/f8bb88f6157045dd5d9b27ccf433d016981032690969aa5c19e332b138c0/numpy-2.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:11c43995255eb4127115956495f43e9343736edb7fcdb0d973defd9de14cd84f", size = 17898106, upload-time = "2025-03-16T18:12:31.487Z" }, - { url = "https://files.pythonhosted.org/packages/2b/93/df59a5a3897c1f036ae8ff845e45f4081bb06943039ae28a3c1c7c780f22/numpy-2.2.4-cp312-cp312-win32.whl", hash = "sha256:65ef3468b53269eb5fdb3a5c09508c032b793da03251d5f8722b1194f1790c00", size = 6311190, upload-time = "2025-03-16T18:12:44.46Z" }, - { url = "https://files.pythonhosted.org/packages/46/69/8c4f928741c2a8efa255fdc7e9097527c6dc4e4df147e3cadc5d9357ce85/numpy-2.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:2aad3c17ed2ff455b8eaafe06bcdae0062a1db77cb99f4b9cbb5f4ecb13c5146", size = 12644305, upload-time = "2025-03-16T18:13:06.864Z" }, - { url = "https://files.pythonhosted.org/packages/2a/d0/bd5ad792e78017f5decfb2ecc947422a3669a34f775679a76317af671ffc/numpy-2.2.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cf4e5c6a278d620dee9ddeb487dc6a860f9b199eadeecc567f777daace1e9e7", size = 20933623, upload-time = "2025-03-16T18:13:43.231Z" }, - { url = "https://files.pythonhosted.org/packages/c3/bc/2b3545766337b95409868f8e62053135bdc7fa2ce630aba983a2aa60b559/numpy-2.2.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1974afec0b479e50438fc3648974268f972e2d908ddb6d7fb634598cdb8260a0", size = 14148681, upload-time = "2025-03-16T18:14:08.031Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/67b24d68a56551d43a6ec9fe8c5f91b526d4c1a46a6387b956bf2d64744e/numpy-2.2.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:79bd5f0a02aa16808fcbc79a9a376a147cc1045f7dfe44c6e7d53fa8b8a79392", size = 5148759, upload-time = "2025-03-16T18:14:18.613Z" }, - { url = "https://files.pythonhosted.org/packages/1c/8b/e2fc8a75fcb7be12d90b31477c9356c0cbb44abce7ffb36be39a0017afad/numpy-2.2.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:3387dd7232804b341165cedcb90694565a6015433ee076c6754775e85d86f1fc", size = 6683092, upload-time = "2025-03-16T18:14:31.386Z" }, - { url = "https://files.pythonhosted.org/packages/13/73/41b7b27f169ecf368b52533edb72e56a133f9e86256e809e169362553b49/numpy-2.2.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f527d8fdb0286fd2fd97a2a96c6be17ba4232da346931d967a0630050dfd298", size = 14081422, upload-time = "2025-03-16T18:14:54.83Z" }, - { url = "https://files.pythonhosted.org/packages/4b/04/e208ff3ae3ddfbafc05910f89546382f15a3f10186b1f56bd99f159689c2/numpy-2.2.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bce43e386c16898b91e162e5baaad90c4b06f9dcbe36282490032cec98dc8ae7", size = 16132202, upload-time = "2025-03-16T18:15:22.035Z" }, - { url = "https://files.pythonhosted.org/packages/fe/bc/2218160574d862d5e55f803d88ddcad88beff94791f9c5f86d67bd8fbf1c/numpy-2.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:31504f970f563d99f71a3512d0c01a645b692b12a63630d6aafa0939e52361e6", size = 15573131, upload-time = "2025-03-16T18:15:48.546Z" }, - { url = "https://files.pythonhosted.org/packages/a5/78/97c775bc4f05abc8a8426436b7cb1be806a02a2994b195945600855e3a25/numpy-2.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:81413336ef121a6ba746892fad881a83351ee3e1e4011f52e97fba79233611fd", size = 17894270, upload-time = "2025-03-16T18:16:20.274Z" }, - { url = "https://files.pythonhosted.org/packages/b9/eb/38c06217a5f6de27dcb41524ca95a44e395e6a1decdc0c99fec0832ce6ae/numpy-2.2.4-cp313-cp313-win32.whl", hash = "sha256:f486038e44caa08dbd97275a9a35a283a8f1d2f0ee60ac260a1790e76660833c", size = 6308141, upload-time = "2025-03-16T18:20:15.297Z" }, - { url = "https://files.pythonhosted.org/packages/52/17/d0dd10ab6d125c6d11ffb6dfa3423c3571befab8358d4f85cd4471964fcd/numpy-2.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:207a2b8441cc8b6a2a78c9ddc64d00d20c303d79fba08c577752f080c4007ee3", size = 12636885, upload-time = "2025-03-16T18:20:36.982Z" }, - { url = "https://files.pythonhosted.org/packages/fa/e2/793288ede17a0fdc921172916efb40f3cbc2aa97e76c5c84aba6dc7e8747/numpy-2.2.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8120575cb4882318c791f839a4fd66161a6fa46f3f0a5e613071aae35b5dd8f8", size = 20961829, upload-time = "2025-03-16T18:16:56.191Z" }, - { url = "https://files.pythonhosted.org/packages/3a/75/bb4573f6c462afd1ea5cbedcc362fe3e9bdbcc57aefd37c681be1155fbaa/numpy-2.2.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a761ba0fa886a7bb33c6c8f6f20213735cb19642c580a931c625ee377ee8bd39", size = 14161419, upload-time = "2025-03-16T18:17:22.811Z" }, - { url = "https://files.pythonhosted.org/packages/03/68/07b4cd01090ca46c7a336958b413cdbe75002286295f2addea767b7f16c9/numpy-2.2.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:ac0280f1ba4a4bfff363a99a6aceed4f8e123f8a9b234c89140f5e894e452ecd", size = 5196414, upload-time = "2025-03-16T18:17:34.066Z" }, - { url = "https://files.pythonhosted.org/packages/a5/fd/d4a29478d622fedff5c4b4b4cedfc37a00691079623c0575978d2446db9e/numpy-2.2.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:879cf3a9a2b53a4672a168c21375166171bc3932b7e21f622201811c43cdd3b0", size = 6709379, upload-time = "2025-03-16T18:17:47.466Z" }, - { url = "https://files.pythonhosted.org/packages/41/78/96dddb75bb9be730b87c72f30ffdd62611aba234e4e460576a068c98eff6/numpy-2.2.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f05d4198c1bacc9124018109c5fba2f3201dbe7ab6e92ff100494f236209c960", size = 14051725, upload-time = "2025-03-16T18:18:11.904Z" }, - { url = "https://files.pythonhosted.org/packages/00/06/5306b8199bffac2a29d9119c11f457f6c7d41115a335b78d3f86fad4dbe8/numpy-2.2.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2f085ce2e813a50dfd0e01fbfc0c12bbe5d2063d99f8b29da30e544fb6483b8", size = 16101638, upload-time = "2025-03-16T18:18:40.749Z" }, - { url = "https://files.pythonhosted.org/packages/fa/03/74c5b631ee1ded596945c12027649e6344614144369fd3ec1aaced782882/numpy-2.2.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:92bda934a791c01d6d9d8e038363c50918ef7c40601552a58ac84c9613a665bc", size = 15571717, upload-time = "2025-03-16T18:19:04.512Z" }, - { url = "https://files.pythonhosted.org/packages/cb/dc/4fc7c0283abe0981e3b89f9b332a134e237dd476b0c018e1e21083310c31/numpy-2.2.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ee4d528022f4c5ff67332469e10efe06a267e32f4067dc76bb7e2cddf3cd25ff", size = 17879998, upload-time = "2025-03-16T18:19:32.52Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2b/878576190c5cfa29ed896b518cc516aecc7c98a919e20706c12480465f43/numpy-2.2.4-cp313-cp313t-win32.whl", hash = "sha256:05c076d531e9998e7e694c36e8b349969c56eadd2cdcd07242958489d79a7286", size = 6366896, upload-time = "2025-03-16T18:19:43.55Z" }, - { url = "https://files.pythonhosted.org/packages/3e/05/eb7eec66b95cf697f08c754ef26c3549d03ebd682819f794cb039574a0a6/numpy-2.2.4-cp313-cp313t-win_amd64.whl", hash = "sha256:188dcbca89834cc2e14eb2f106c96d6d46f200fe0200310fc29089657379c58d", size = 12739119, upload-time = "2025-03-16T18:20:03.94Z" }, +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, ] [[package]] name = "oshconnect" -version = "0.5.0a0" +version = "0.5.1a22" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, @@ -627,17 +864,32 @@ dependencies = [ { name = "pydantic" }, { name = "requests" }, { name = "shapely" }, + { name = "urllib3" }, { name = "websockets" }, ] [package.optional-dependencies] +av = [ + { name = "av" }, + { name = "pillow" }, +] dev = [ { name = "flake8" }, - { name = "mkdocs-material" }, - { name = "mkdocstrings", extra = ["python"] }, + { name = "furo" }, + { name = "interrogate" }, + { name = "myst-parser" }, + { name = "pygments" }, { name = "pytest" }, + { name = "pytest-cov" }, { name = "sphinx" }, - { name = "sphinx-rtd-theme" }, + { name = "sphinx-copybutton" }, + { name = "sphinxcontrib-mermaid" }, +] +flatbuffers = [ + { name = "flatbuffers" }, +] +protobuf = [ + { name = "protobuf" }, ] tinydb = [ { name = "tinydb" }, @@ -645,38 +897,38 @@ tinydb = [ [package.metadata] requires-dist = [ - { name = "aiohttp", specifier = ">=3.12.15" }, - { name = "flake8", marker = "extra == 'dev'", specifier = ">=7.2.0" }, - { name = "mkdocs-material", marker = "extra == 'dev'", specifier = ">=9.5.0" }, - { name = "mkdocstrings", extras = ["python"], marker = "extra == 'dev'", specifier = ">=0.26.0" }, + { name = "aiohttp", specifier = ">=3.13.5" }, + { name = "av", marker = "extra == 'av'", specifier = ">=15.0.0" }, + { name = "flake8", marker = "extra == 'dev'", specifier = ">=7.3.0" }, + { name = "flatbuffers", marker = "extra == 'flatbuffers'", specifier = ">=24.0" }, + { name = "furo", marker = "extra == 'dev'", specifier = ">=2025.12.19" }, + { name = "interrogate", marker = "extra == 'dev'", specifier = ">=1.7.0" }, + { name = "myst-parser", marker = "extra == 'dev'", specifier = ">=5.0.0" }, { name = "paho-mqtt", specifier = ">=2.1.0" }, - { name = "pydantic", specifier = ">=2.12.5,<3.0.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.5" }, - { name = "requests" }, + { name = "pillow", marker = "extra == 'av'", specifier = ">=11.0.0" }, + { name = "protobuf", marker = "extra == 'protobuf'", specifier = ">=7.35.0" }, + { name = "pydantic", specifier = ">=2.13.4,<3.0.0" }, + { name = "pygments", marker = "extra == 'dev'", specifier = ">=2.20.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.0.0" }, + { name = "requests", specifier = ">=2.33.1" }, { name = "shapely", specifier = ">=2.1.2,<3.0.0" }, - { name = "sphinx", marker = "extra == 'dev'", specifier = ">=7.4.7" }, - { name = "sphinx-rtd-theme", marker = "extra == 'dev'", specifier = ">=2.0.0" }, - { name = "tinydb", marker = "extra == 'tinydb'", specifier = ">=4.8.0,<5.0.0" }, - { name = "websockets", specifier = ">=12.0,<16.0" }, + { name = "sphinx", marker = "extra == 'dev'", specifier = ">=9.0.0" }, + { name = "sphinx-copybutton", marker = "extra == 'dev'", specifier = ">=0.5.2" }, + { name = "sphinxcontrib-mermaid", marker = "extra == 'dev'", specifier = ">=2.0.0" }, + { name = "tinydb", marker = "extra == 'tinydb'", specifier = ">=4.8.2,<5.0.0" }, + { name = "urllib3", specifier = ">=2.7.0" }, + { name = "websockets", specifier = ">=16.0,<17.0" }, ] -provides-extras = ["dev", "tinydb"] +provides-extras = ["protobuf", "flatbuffers", "av", "dev", "tinydb"] [[package]] name = "packaging" -version = "24.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950, upload-time = "2024-11-08T09:47:47.202Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" }, -] - -[[package]] -name = "paginate" -version = "0.5.7" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] @@ -689,101 +941,203 @@ wheels = [ ] [[package]] -name = "pathspec" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, -] - -[[package]] -name = "platformdirs" -version = "4.9.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, ] [[package]] name = "pluggy" -version = "1.5.0" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "propcache" -version = "0.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674, upload-time = "2025-06-09T22:54:30.551Z" }, - { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570, upload-time = "2025-06-09T22:54:32.296Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094, upload-time = "2025-06-09T22:54:33.929Z" }, - { url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958, upload-time = "2025-06-09T22:54:35.186Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894, upload-time = "2025-06-09T22:54:36.708Z" }, - { url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672, upload-time = "2025-06-09T22:54:38.062Z" }, - { url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395, upload-time = "2025-06-09T22:54:39.634Z" }, - { url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510, upload-time = "2025-06-09T22:54:41.565Z" }, - { url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949, upload-time = "2025-06-09T22:54:43.038Z" }, - { url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258, upload-time = "2025-06-09T22:54:44.376Z" }, - { url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036, upload-time = "2025-06-09T22:54:46.243Z" }, - { url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684, upload-time = "2025-06-09T22:54:47.63Z" }, - { url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562, upload-time = "2025-06-09T22:54:48.982Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142, upload-time = "2025-06-09T22:54:50.424Z" }, - { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711, upload-time = "2025-06-09T22:54:52.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479, upload-time = "2025-06-09T22:54:53.234Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" }, - { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" }, - { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" }, - { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" }, - { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" }, - { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" }, - { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" }, - { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" }, - { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" }, - { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" }, - { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload-time = "2025-06-09T22:55:15.284Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload-time = "2025-06-09T22:55:16.445Z" }, - { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" }, - { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" }, - { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" }, - { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" }, - { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" }, - { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" }, - { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" }, - { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" }, - { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" }, - { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" }, - { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" }, - { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" }, - { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" }, - { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/fd/5b1491d9e4b586d621c54f4c36b888714164b6875f8d6afa3f9072906a51/protobuf-7.35.0.tar.gz", hash = "sha256:a2efd84605f41e559f1881b0912b44099d0a2ac9bf46b3474823f10fb393b0e6", size = 458677, upload-time = "2026-05-19T23:02:29.197Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ee/93d06e358a4aa32280b00e722d3ea0a1f25fc3cc5778d80581c9cca2c10e/protobuf-7.35.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:66be6c513931c794fa92c080ffee41671390da3d79da219cf9c0c0907f035dda", size = 433225, upload-time = "2026-05-19T23:02:19.884Z" }, + { url = "https://files.pythonhosted.org/packages/8b/39/1c76c2da93f3c507e958e0aecee2391cc44d4625de6c728bbc555195b5a8/protobuf-7.35.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:fcbe42a4ac09d3ec9c987ddfcd956afd0b15f1ff613bd8371bde9405ffd5c8e5", size = 328847, upload-time = "2026-05-19T23:02:22.3Z" }, + { url = "https://files.pythonhosted.org/packages/91/1a/39f7ce90a238c1a987a4d81ec26379e02ca0aff367de68e4a1fa474215b9/protobuf-7.35.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:4cbf5cc286130e06a6c9bbefac442431173906dfcc979712183d4adcc01b37ee", size = 344030, upload-time = "2026-05-19T23:02:23.591Z" }, + { url = "https://files.pythonhosted.org/packages/70/5b/6baf9008817964454055ff3fe65f1de0b5f1e26c80c82f7fb108b7cd4ea3/protobuf-7.35.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:6c0f98f10c8a05ea30f8993dfef2de093d27b490fdae78bb60c8343795d55011", size = 327130, upload-time = "2026-05-19T23:02:24.637Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e5/e46adb0badc388bfb84877a5f9f026aff63f60e611016cf64dbe77e05446/protobuf-7.35.0-cp310-abi3-win32.whl", hash = "sha256:4c4617b83ade0e279d1d2bfe04025a1adb87f9ed657de038620dc0ff959357f6", size = 428946, upload-time = "2026-05-19T23:02:25.741Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ab/547fbd9e16d879dd13c167478f8ae0a83a428008ca07a5e06acdc23ad473/protobuf-7.35.0-cp310-abi3-win_amd64.whl", hash = "sha256:f05bcadf9a2a6b8dda047007075135fb7d08c73d9177aabc067e1be46881a201", size = 439996, upload-time = "2026-05-19T23:02:26.808Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ef/50433d346c56657a70d27f156c7b349ac59a068b01de4eb796e747eecc43/protobuf-7.35.0-py3-none-any.whl", hash = "sha256:c13f325cf242bad135c350629eeb5d54b24228eb472fb3e2e9ebbd4c5dc20ca0", size = 171659, upload-time = "2026-05-19T23:02:27.842Z" }, +] + +[[package]] +name = "py" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/ff/fec109ceb715d2a6b4c4a85a61af3b40c723a961e8828319fbcb15b868dc/py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", size = 207796, upload-time = "2021-11-04T17:17:01.377Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378", size = 98708, upload-time = "2021-11-04T17:17:00.152Z" }, ] [[package]] name = "pycodestyle" -version = "2.13.0" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/6e/1f4a62078e4d95d82367f24e685aef3a672abfd27d1a868068fed4ed2254/pycodestyle-2.13.0.tar.gz", hash = "sha256:c8415bf09abe81d9c7f872502a6eee881fbe85d8763dd5b9924bb0a01d67efae", size = 39312, upload-time = "2025-03-29T17:33:30.669Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/be/b00116df1bfb3e0bb5b45e29d604799f7b91dd861637e4d448b4e09e6a3e/pycodestyle-2.13.0-py2.py3-none-any.whl", hash = "sha256:35863c5974a271c7a726ed228a14a4f6daf49df369d8c50cd9a6f58a5e143ba9", size = 31424, upload-time = "2025-03-29T17:33:29.405Z" }, + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, ] [[package]] name = "pydantic" -version = "2.12.5" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -791,138 +1145,132 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [[package]] name = "pydantic-core" -version = "2.41.5" +version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, ] [[package]] name = "pyflakes" -version = "3.3.2" +version = "3.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/cc/1df338bd7ed1fa7c317081dcf29bf2f01266603b301e6858856d346a12b3/pyflakes-3.3.2.tar.gz", hash = "sha256:6dfd61d87b97fba5dcfaaf781171ac16be16453be6d816147989e7f6e6a9576b", size = 64175, upload-time = "2025-03-31T13:21:20.34Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/40/b293a4fa769f3b02ab9e387c707c4cbdc34f073f945de0386107d4e669e6/pyflakes-3.3.2-py2.py3-none-any.whl", hash = "sha256:5039c8339cbb1944045f4ee5466908906180f13cc99cc9949348d10f82a5c32a", size = 63164, upload-time = "2025-03-31T13:21:18.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, ] [[package]] name = "pygments" -version = "2.19.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, -] - -[[package]] -name = "pymdown-extensions" -version = "10.21.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown" }, - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pytest" -version = "8.3.5" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, + { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] -name = "python-dateutil" -version = "2.9.0.post0" +name = "pytest-cov" +version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six" }, + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] @@ -971,21 +1319,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] -[[package]] -name = "pyyaml-env-tag" -version = "1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, -] - [[package]] name = "requests" -version = "2.32.3" +version = "2.33.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -993,9 +1329,18 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, ] [[package]] @@ -1050,26 +1395,26 @@ wheels = [ ] [[package]] -name = "six" -version = "1.17.0" +name = "snowballstemmer" +version = "3.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, ] [[package]] -name = "snowballstemmer" -version = "2.2.0" +name = "soupsieve" +version = "2.8.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/44/7b/af302bebf22c749c56c9c3e8ae13190b5b5db37a33d9068652e8f73b7089/snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1", size = 86699, upload-time = "2021-11-16T18:38:38.009Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/dc/c02e01294f7265e63a7315fe086dd1df7dacb9f840a804da846b96d01b96/snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a", size = 93002, upload-time = "2021-11-16T18:38:34.792Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, ] [[package]] name = "sphinx" -version = "7.4.7" +version = "9.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "alabaster" }, @@ -1081,6 +1426,7 @@ dependencies = [ { name = "packaging" }, { name = "pygments" }, { name = "requests" }, + { name = "roman-numerals" }, { name = "snowballstemmer" }, { name = "sphinxcontrib-applehelp" }, { name = "sphinxcontrib-devhelp" }, @@ -1089,23 +1435,33 @@ dependencies = [ { name = "sphinxcontrib-qthelp" }, { name = "sphinxcontrib-serializinghtml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911, upload-time = "2024-07-20T14:46:56.059Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/ef/153f6803c5d5f8917dbb7f7fcf6d34a871ede3296fa89c2c703f5f8a6c8e/sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239", size = 3401624, upload-time = "2024-07-20T14:46:52.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, ] [[package]] -name = "sphinx-rtd-theme" -version = "2.0.0" +name = "sphinx-basic-ng" +version = "1.0.0b2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "docutils" }, { name = "sphinx" }, - { name = "sphinxcontrib-jquery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fe/33/2a35a9cdbfda9086bda11457bcc872173ab3565b16b6d7f6b3efaa6dc3d6/sphinx_rtd_theme-2.0.0.tar.gz", hash = "sha256:bd5d7b80622406762073a04ef8fadc5f9151261563d47027de09910ce03afe6b", size = 2785005, upload-time = "2023-11-28T04:14:03.104Z" } +sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736, upload-time = "2023-07-08T18:40:54.166Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/46/00fda84467815c29951a9c91e3ae7503c409ddad04373e7cfc78daad4300/sphinx_rtd_theme-2.0.0-py2.py3-none-any.whl", hash = "sha256:ec93d0856dc280cf3aee9a4c9807c60e027c7f7b461b77aeffed682e68f0e586", size = 2824721, upload-time = "2023-11-28T04:13:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b", size = 22496, upload-time = "2023-07-08T18:40:52.659Z" }, +] + +[[package]] +name = "sphinx-copybutton" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd", size = 23039, upload-time = "2023-04-14T08:10:22.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e", size = 13343, upload-time = "2023-04-14T08:10:20.844Z" }, ] [[package]] @@ -1136,24 +1492,26 @@ wheels = [ ] [[package]] -name = "sphinxcontrib-jquery" -version = "4.1" +name = "sphinxcontrib-jsmath" +version = "1.0.1" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "sphinx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" }, + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, ] [[package]] -name = "sphinxcontrib-jsmath" -version = "1.0.1" +name = "sphinxcontrib-mermaid" +version = "2.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +dependencies = [ + { name = "jinja2" }, + { name = "pyyaml" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/75/3a1cc926da8c563c58ddc124a7b3fe5ccadcae96c96e3a6f8ac3653a210a/sphinxcontrib_mermaid-2.0.2.tar.gz", hash = "sha256:f09576c78ca93fa0e3034fd9c45aaffa7c44ab449de9c43b8b8d262afe52bc66", size = 19265, upload-time = "2026-05-05T13:59:02.959Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/16/8d/93be7e0f7fa915a576859b3bfac7a7baa3303181c44d7db7eefbd3e8a69f/sphinxcontrib_mermaid-2.0.2-py3-none-any.whl", hash = "sha256:d862e514991279fb4816302c5cfe167d2557bf3ce7125ae0cb47dac80a0f46ce", size = 14094, upload-time = "2026-05-05T13:59:01.585Z" }, ] [[package]] @@ -1174,6 +1532,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, ] +[[package]] +name = "tabulate" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, +] + [[package]] name = "tinydb" version = "4.8.2" @@ -1206,118 +1573,158 @@ wheels = [ [[package]] name = "urllib3" -version = "2.4.0" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672, upload-time = "2025-04-10T15:23:39.232Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" }, -] - -[[package]] -name = "watchdog" -version = "6.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, - { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, - { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, - { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, - { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, - { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, - { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, - { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, - { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, - { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, - { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, - { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, - { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] name = "websockets" -version = "12.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2e/62/7a7874b7285413c954a4cca3c11fd851f11b2fe5b4ae2d9bee4f6d9bdb10/websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b", size = 104994, upload-time = "2023-10-21T14:21:11.88Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/6d/23cc898647c8a614a0d9ca703695dd04322fb5135096a20c2684b7c852b6/websockets-12.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0e6e2711d5a8e6e482cacb927a49a3d432345dfe7dea8ace7b5790df5932e4df", size = 124061, upload-time = "2023-10-21T14:20:02.221Z" }, - { url = "https://files.pythonhosted.org/packages/39/34/364f30fdf1a375e4002a26ee3061138d1571dfda6421126127d379d13930/websockets-12.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:dbcf72a37f0b3316e993e13ecf32f10c0e1259c28ffd0a85cee26e8549595fbc", size = 121296, upload-time = "2023-10-21T14:20:03.591Z" }, - { url = "https://files.pythonhosted.org/packages/2e/00/96ae1c9dcb3bc316ef683f2febd8c97dde9f254dc36c3afc65c7645f734c/websockets-12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12743ab88ab2af1d17dd4acb4645677cb7063ef4db93abffbf164218a5d54c6b", size = 121326, upload-time = "2023-10-21T14:20:04.956Z" }, - { url = "https://files.pythonhosted.org/packages/af/f1/bba1e64430685dd456c1a1fd6b0c791ae33104967b928aefeff261761e8d/websockets-12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b645f491f3c48d3f8a00d1fce07445fab7347fec54a3e65f0725d730d5b99cb", size = 131807, upload-time = "2023-10-21T14:20:06.153Z" }, - { url = "https://files.pythonhosted.org/packages/62/3b/98ee269712f37d892b93852ce07b3e6d7653160ca4c0d4f8c8663f8021f8/websockets-12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9893d1aa45a7f8b3bc4510f6ccf8db8c3b62120917af15e3de247f0780294b92", size = 130751, upload-time = "2023-10-21T14:20:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/f1/00/d6f01ca2b191f8b0808e4132ccd2e7691f0453cbd7d0f72330eb97453c3a/websockets-12.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f38a7b376117ef7aff996e737583172bdf535932c9ca021746573bce40165ed", size = 131176, upload-time = "2023-10-21T14:20:09.212Z" }, - { url = "https://files.pythonhosted.org/packages/af/9c/703ff3cd8109dcdee6152bae055d852ebaa7750117760ded697ab836cbcf/websockets-12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f764ba54e33daf20e167915edc443b6f88956f37fb606449b4a5b10ba42235a5", size = 136246, upload-time = "2023-10-21T14:20:10.423Z" }, - { url = "https://files.pythonhosted.org/packages/0b/a5/1a38fb85a456b9dc874ec984f3ff34f6550eafd17a3da28753cd3c1628e8/websockets-12.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:1e4b3f8ea6a9cfa8be8484c9221ec0257508e3a1ec43c36acdefb2a9c3b00aa2", size = 135466, upload-time = "2023-10-21T14:20:11.826Z" }, - { url = "https://files.pythonhosted.org/packages/3c/98/1261f289dff7e65a38d59d2f591de6ed0a2580b729aebddec033c4d10881/websockets-12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9fdf06fd06c32205a07e47328ab49c40fc1407cdec801d698a7c41167ea45113", size = 136083, upload-time = "2023-10-21T14:20:13.451Z" }, - { url = "https://files.pythonhosted.org/packages/a9/1c/f68769fba63ccb9c13fe0a25b616bd5aebeef1c7ddebc2ccc32462fb784d/websockets-12.0-cp312-cp312-win32.whl", hash = "sha256:baa386875b70cbd81798fa9f71be689c1bf484f65fd6fb08d051a0ee4e79924d", size = 124460, upload-time = "2023-10-21T14:20:14.719Z" }, - { url = "https://files.pythonhosted.org/packages/20/52/8915f51f9aaef4e4361c89dd6cf69f72a0159f14e0d25026c81b6ad22525/websockets-12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae0a5da8f35a5be197f328d4727dbcfafa53d1824fac3d96cdd3a642fe09394f", size = 124985, upload-time = "2023-10-21T14:20:15.817Z" }, - { url = "https://files.pythonhosted.org/packages/79/4d/9cc401e7b07e80532ebc8c8e993f42541534da9e9249c59ee0139dcb0352/websockets-12.0-py3-none-any.whl", hash = "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e", size = 118370, upload-time = "2023-10-21T14:21:10.075Z" }, +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] [[package]] name = "yarl" -version = "1.20.1" +version = "1.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667, upload-time = "2025-06-10T00:43:44.369Z" }, - { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025, upload-time = "2025-06-10T00:43:46.295Z" }, - { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709, upload-time = "2025-06-10T00:43:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/99/da/4d798025490e89426e9f976702e5f9482005c548c579bdae792a4c37769e/yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee", size = 352287, upload-time = "2025-06-10T00:43:49.924Z" }, - { url = "https://files.pythonhosted.org/packages/1a/26/54a15c6a567aac1c61b18aa0f4b8aa2e285a52d547d1be8bf48abe2b3991/yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819", size = 345429, upload-time = "2025-06-10T00:43:51.7Z" }, - { url = "https://files.pythonhosted.org/packages/d6/95/9dcf2386cb875b234353b93ec43e40219e14900e046bf6ac118f94b1e353/yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16", size = 365429, upload-time = "2025-06-10T00:43:53.494Z" }, - { url = "https://files.pythonhosted.org/packages/91/b2/33a8750f6a4bc224242a635f5f2cff6d6ad5ba651f6edcccf721992c21a0/yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6", size = 363862, upload-time = "2025-06-10T00:43:55.766Z" }, - { url = "https://files.pythonhosted.org/packages/98/28/3ab7acc5b51f4434b181b0cee8f1f4b77a65919700a355fb3617f9488874/yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd", size = 355616, upload-time = "2025-06-10T00:43:58.056Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/f666894aa947a371724ec7cd2e5daa78ee8a777b21509b4252dd7bd15e29/yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a", size = 339954, upload-time = "2025-06-10T00:43:59.773Z" }, - { url = "https://files.pythonhosted.org/packages/f1/81/5f466427e09773c04219d3450d7a1256138a010b6c9f0af2d48565e9ad13/yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38", size = 365575, upload-time = "2025-06-10T00:44:02.051Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e3/e4b0ad8403e97e6c9972dd587388940a032f030ebec196ab81a3b8e94d31/yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef", size = 365061, upload-time = "2025-06-10T00:44:04.196Z" }, - { url = "https://files.pythonhosted.org/packages/ac/99/b8a142e79eb86c926f9f06452eb13ecb1bb5713bd01dc0038faf5452e544/yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f", size = 364142, upload-time = "2025-06-10T00:44:06.527Z" }, - { url = "https://files.pythonhosted.org/packages/34/f2/08ed34a4a506d82a1a3e5bab99ccd930a040f9b6449e9fd050320e45845c/yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8", size = 381894, upload-time = "2025-06-10T00:44:08.379Z" }, - { url = "https://files.pythonhosted.org/packages/92/f8/9a3fbf0968eac704f681726eff595dce9b49c8a25cd92bf83df209668285/yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a", size = 383378, upload-time = "2025-06-10T00:44:10.51Z" }, - { url = "https://files.pythonhosted.org/packages/af/85/9363f77bdfa1e4d690957cd39d192c4cacd1c58965df0470a4905253b54f/yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004", size = 374069, upload-time = "2025-06-10T00:44:12.834Z" }, - { url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249, upload-time = "2025-06-10T00:44:14.731Z" }, - { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710, upload-time = "2025-06-10T00:44:16.716Z" }, - { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" }, - { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" }, - { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" }, - { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" }, - { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" }, - { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" }, - { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" }, - { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" }, - { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" }, - { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" }, - { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" }, - { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" }, - { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" }, - { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload-time = "2025-06-10T00:44:49.164Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload-time = "2025-06-10T00:44:51.182Z" }, - { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" }, - { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" }, - { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" }, - { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" }, - { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" }, - { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" }, - { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" }, - { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" }, - { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" }, - { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" }, - { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" }, - { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" }, - { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" }, - { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" }, - { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" }, - { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, - { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, + { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, + { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, + { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, + { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, + { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, + { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, + { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, + { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, + { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, + { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, + { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, + { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, + { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, + { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, + { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, + { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, + { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, + { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, + { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, + { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, + { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, + { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, + { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ]