From b5b930490c131383718e952325ccc0c9c7cd487c Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 1 May 2026 20:39:04 -0500 Subject: [PATCH 01/29] large change to test structure and coverage. These tests are mostly AI generated and will be manually reviewed as time permits --- .github/workflows/tests.yaml | 38 ++ pyproject.toml | 5 +- tests/test_api_helper.py | 18 - tests/test_imports.py | 202 +++------- tests/test_node.py | 24 ++ tests/test_oshconnect.py | 145 +++---- tests/test_serialization.py | 10 - tests/test_streamable_resources.py | 12 - tests/test_swe_components.py | 573 ++++++++++++++++++++++++++++ tests/test_swe_name_validation.py | 394 ------------------- tests/test_swe_schema_validation.py | 371 ------------------ tests/test_time_management.py | 22 ++ uv.lock | 2 +- 13 files changed, 778 insertions(+), 1038 deletions(-) create mode 100644 .github/workflows/tests.yaml delete mode 100644 tests/test_api_helper.py create mode 100644 tests/test_node.py delete mode 100644 tests/test_serialization.py delete mode 100644 tests/test_streamable_resources.py create mode 100644 tests/test_swe_components.py delete mode 100644 tests/test_swe_name_validation.py delete mode 100644 tests/test_swe_schema_validation.py create mode 100644 tests/test_time_management.py diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..4083f73 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,38 @@ +name: Tests +on: [ push, pull_request, 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 + run: uv run --python ${{ matrix.python-version }} pytest -v -m "not network" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 06ee198..007d9d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "oshconnect" -version = "0.5.0a0" +version = "0.5.0a1" 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 = [ @@ -31,3 +31,6 @@ 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`).", +] 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_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_node.py b/tests/test_node.py new file mode 100644 index 0000000..e9369a9 --- /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_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' \ No newline at end of file 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_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_components.py b/tests/test_swe_components.py new file mode 100644 index 0000000..d1b159f --- /dev/null +++ b/tests/test_swe_components.py @@ -0,0 +1,573 @@ +"""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, + JSONDatastreamRecordSchema, + 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 = JSONDatastreamRecordSchema.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. + 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], + }, + }) + + +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, 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) + + +# --- 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", 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 (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")], + }) + + +# --- 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_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_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..7cd5de5 100644 --- a/uv.lock +++ b/uv.lock @@ -619,7 +619,7 @@ wheels = [ [[package]] name = "oshconnect" -version = "0.5.0a0" +version = "0.5.0a1" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, From c79d5d29ac36d3a994a8f362347a81a1f9bb1e16 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 1 May 2026 20:56:20 -0500 Subject: [PATCH 02/29] add doc coverage --- .github/workflows/tests.yaml | 17 ++++- README.md | 54 ++++++++++++++ pyproject.toml | 40 +++++++++++ uv.lock | 136 +++++++++++++++++++++++++++++++++++ 4 files changed, 245 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 4083f73..8989ea8 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -34,5 +34,18 @@ jobs: # 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 - run: uv run --python ${{ matrix.python-version }} pytest -v -m "not network" \ No newline at end of file + - 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 \ No newline at end of file diff --git a/README.md b/README.md index 0a24c55..36aa365 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,60 @@ 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) +## Running Tests + +```bash +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 +``` + +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 pytest -m "not network" # what CI runs +uv run pytest -m network # only the live-server tests +``` + +## Test Coverage + +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 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) +``` + +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 + +[`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 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) +``` + +Once we agree on a baseline, raise `[tool.interrogate].fail-under` from `0` so +new code without docstrings starts failing locally and in CI. + ## Generating the Docs The documentation is built with [MkDocs](https://www.mkdocs.org/) using the diff --git a/pyproject.toml b/pyproject.toml index 007d9d7..13f12ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,8 @@ dependencies = [ dev = [ "flake8>=7.2.0", "pytest>=8.3.5", + "pytest-cov>=5.0.0", + "interrogate>=1.7.0", "sphinx>=7.4.7", "sphinx-rtd-theme>=2.0.0", "mkdocs-material>=9.5.0", @@ -34,3 +36,41 @@ 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/uv.lock b/uv.lock index 7cd5de5..e1cc0e8 100644 --- a/uv.lock +++ b/uv.lock @@ -189,6 +189,90 @@ 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" @@ -320,6 +404,22 @@ 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" }, ] +[[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]] name = "jinja2" version = "3.1.6" @@ -633,9 +733,11 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "flake8" }, + { name = "interrogate" }, { name = "mkdocs-material" }, { name = "mkdocstrings", extra = ["python"] }, { name = "pytest" }, + { name = "pytest-cov" }, { name = "sphinx" }, { name = "sphinx-rtd-theme" }, ] @@ -647,11 +749,13 @@ tinydb = [ requires-dist = [ { name = "aiohttp", specifier = ">=3.12.15" }, { name = "flake8", marker = "extra == 'dev'", specifier = ">=7.2.0" }, + { name = "interrogate", marker = "extra == 'dev'", specifier = ">=1.7.0" }, { name = "mkdocs-material", marker = "extra == 'dev'", specifier = ">=9.5.0" }, { name = "mkdocstrings", extras = ["python"], marker = "extra == 'dev'", specifier = ">=0.26.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 = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0.0" }, { name = "requests" }, { name = "shapely", specifier = ">=2.1.2,<3.0.0" }, { name = "sphinx", marker = "extra == 'dev'", specifier = ">=7.4.7" }, @@ -772,6 +876,15 @@ wheels = [ { 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" }, ] +[[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" @@ -913,6 +1026,20 @@ 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" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +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/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]] name = "python-dateutil" version = "2.9.0.post0" @@ -1174,6 +1301,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" From eb290cb2bf036cad60716366dfaa0ea30aa2636b Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 1 May 2026 21:59:42 -0500 Subject: [PATCH 03/29] rename some troublesome methods related to internal datastores and improve overall doc coverage of streamableresource.py --- src/oshconnect/datastores/sqlite_store.py | 34 +- src/oshconnect/oshconnectapi.py | 2 +- src/oshconnect/streamableresource.py | 461 +++++++++++++++++++--- tests/test_node.py | 10 +- 4 files changed, 439 insertions(+), 68 deletions(-) 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/oshconnectapi.py b/src/oshconnect/oshconnectapi.py index a50f802..8105915 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 diff --git a/src/oshconnect/streamableresource.py b/src/oshconnect/streamableresource.py index ecd6c56..80f9709 100644 --- a/src/oshconnect/streamableresource.py +++ b/src/oshconnect/streamableresource.py @@ -5,6 +5,40 @@ # Contact Email: ian@botts-inc.com # ============================================================================= +""" +Streamable resource hierarchy: the user-facing primitives for talking to an +OpenSensorHub server. + +Object model +------------ + +:: + + Node # connection to one OSH server + ├── APIHelper # builds and executes HTTP requests + └── System[] # discovered or user-created sensor systems + ├── Datastream[] # output channels (observations) + └── ControlStream[] # input channels (commands + status) + +`Node`, `System`, `Datastream`, and `ControlStream` are the types most user +code touches. `StreamableResource` is the abstract base that powers MQTT +streaming, WebSocket connections, and inbound/outbound message queues for +all three concrete subclasses. + +Conventions +----------- + +- Construction → `initialize()` (sets up MQTT subscriptions and the WS URL) + → `start()` (opens the streaming loop). `stop()` tears down. +- Inbound MQTT messages land in `_inbound_deque`; outbound payloads queued + via `publish()` / `insert_data()` flow through `_outbound_deque`. +- Resource creation (`add_insert_datastream`, `add_and_insert_control_stream`, + `insert_self`) goes through the parent `Node`'s `APIHelper` and a + `Location` header on the response is parsed to capture the new server-side + ID. +- `StreamableModes`: `PUSH` = we publish, `PULL` = we subscribe, + `BIDIRECTIONAL` = both. Defaults to `PUSH` on construction. +""" from __future__ import annotations import asyncio @@ -43,19 +77,32 @@ @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 @@ -65,20 +112,34 @@ def __init__(self, base_url, *args, verify_ssl=True, **kwargs): 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 @@ -87,29 +148,61 @@ def __init__(self, session_tokens: dict[str, str] = None): 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): + 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 @@ -128,7 +221,7 @@ 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): + enable_mqtt: bool = False, mqtt_port: int = 1883): self._id = f'node-{uuid.uuid4()}' self.protocol = protocol self.address = address @@ -154,43 +247,58 @@ def __init__(self, protocol: str, address: str, port: int, 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') + 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): + def get_id(self) -> str: + """Return the locally-generated node ID (``node-``).""" return self._id - def get_address(self): + def get_address(self) -> str: + """Return the configured server hostname/IP.""" return self.address - def get_port(self): + def get_port(self) -> int: + """Return the configured server port.""" return self.port - def get_api_endpoint(self): + 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): + 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): + def discover_systems(self) -> list[System] | None: + """GET ``/systems`` and create a `System` for each entry. + + 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. + """ result = self._api_helper.retrieve_resource(APIResourceTypes.SYSTEM, req_headers={}) if result.ok: @@ -211,10 +319,16 @@ def discover_systems(self): return None def add_new_system(self, system: System): + """Attach a system to this node without inserting it server-side. + + Use `add_system(system, insert_resource=True)` if you also want to + POST it to the server. + """ system.set_parent_node(self) self._systems.append(system) def get_api_helper(self) -> APIHelper: + """Return the `APIHelper` this node uses for HTTP calls.""" return self._api_helper # System Management @@ -233,6 +347,7 @@ def add_system(self, system: System, insert_resource: bool = False): 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): @@ -244,14 +359,30 @@ def register_with_session_manager(self, session_manager: SessionManager): 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`. + + :raises ValueError: if the node was created without a SessionManager. + """ 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 the `OSHClientSession` bound to this node.""" return self._client_session - def serialize(self) -> dict: + 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, @@ -263,7 +394,7 @@ def serialize(self) -> dict: "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, + "_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) @@ -271,12 +402,12 @@ def serialize(self) -> dict: data["description"] = getattr(self, "description", None) datastreams = getattr(self, "datastreams", None) if datastreams is not None: - data["datastreams"] = [ds.serialize() for ds in datastreams] + 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.serialize() for cc in control_channels] + data["control_channels"] = [cc.to_storage_dict() for cc in control_channels] else: data["control_channels"] = None underlying = getattr(self, "_underlying_resource", None) @@ -295,7 +426,20 @@ def serialize(self) -> dict: return data @classmethod - def deserialize(cls, data: dict, session_manager: 'SessionManager' = None) -> 'Node': + 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. + """ node = cls( protocol=data["protocol"], address=data["address"], @@ -308,16 +452,18 @@ def deserialize(cls, data: dict, session_manager: 'SessionManager' = None) -> 'N ) node._id = data["_id"] node.is_secure = data.get("is_secure", False) - # Register with the session manager before deserializing child resources, + # 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.deserialize(sys, node) for sys in data.get("_systems", [])] if data.get( + node._systems = [System.from_storage_dict(sys, node) for sys in data.get("_systems", [])] if data.get( "_systems") is not None else [] return node class Status(Enum): + """Lifecycle states a `StreamableResource` transitions through: + ``STOPPED → INITIALIZING → INITIALIZED → STARTING → STARTED → STOPPING → STOPPED``.""" INITIALIZING = "initializing" INITIALIZED = "initialized" STARTING = "starting" @@ -327,6 +473,12 @@ class Status(Enum): 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" @@ -336,6 +488,18 @@ class StreamableModes(Enum): 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 @@ -365,12 +529,23 @@ def __init__(self, node: Node, connection_mode: StreamableModes = StreamableMode 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 @@ -393,6 +568,9 @@ def initialize(self): 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 @@ -400,6 +578,12 @@ def start(self): 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: @@ -413,6 +597,12 @@ async def stream(self): 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 @@ -529,6 +719,11 @@ async def _write_to_ws(self, ws): 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" @@ -536,24 +731,32 @@ def stop(self): 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: @@ -572,9 +775,12 @@ def get_msg_writer_queue(self) -> Queue: 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: dict): @@ -587,6 +793,13 @@ def insert_data(self, data: dict): self._msg_writer_queue.put_nowait(data_bytes) 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 @@ -649,14 +862,22 @@ def _emit_inbound_event(self, msg): """Hook for subclasses to publish EventHandler events on incoming MQTT messages.""" pass - def get_inbound_deque(self): + def get_inbound_deque(self) -> deque: + """Return the deque that receives inbound MQTT message payloads.""" return self._inbound_deque - def get_outbound_deque(self): + def get_outbound_deque(self) -> deque: + """Return the deque feeding outbound MQTT publishes.""" return self._outbound_deque - def serialize(self) -> dict: - """Serializes common attributes of StreamableResource, safely handling missing/None attributes.""" + 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) @@ -676,8 +897,11 @@ def serialize(self) -> dict: } @classmethod - def deserialize(cls, data: dict, node: 'Node') -> 'StreamableResource': - """Deserializes common attributes. Subclasses should override and call super().""" + 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") @@ -690,6 +914,15 @@ def deserialize(cls, data: dict, node: 'Node') -> 'StreamableResource': 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. + """ name: str label: str datastreams: list[Datastream] @@ -720,6 +953,10 @@ def __init__(self, name: str, label: str, urn: str, parent_node: Node, **kwargs) self._underlying_resource = self.to_system_resource() 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. + """ res = self._parent_node.get_api_helper().get_resource(APIResourceTypes.SYSTEM, self._resource_id, APIResourceTypes.DATASTREAM) datastream_json = res.json()['items'] @@ -736,6 +973,10 @@ def discover_datastreams(self) -> list[Datastream]: 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. + """ res = self._parent_node.get_api_helper().get_resource(APIResourceTypes.SYSTEM, self._resource_id, APIResourceTypes.CONTROL_CHANNEL) controlstream_json = res.json()['items'] @@ -753,6 +994,12 @@ def discover_controlstreams(self) -> list[ControlStream]: @staticmethod def from_system_resource(system_resource: SystemResource, parent_node: Node) -> System: + """Build a `System` from an already-parsed `SystemResource`. + + Handles both shapes the OSH server emits: the GeoJSON form (with a + ``properties`` block carrying ``name``/``uid``) and the flat form + (``name``/``label``/``urn`` directly on the resource). + """ other_props = system_resource.model_dump() print(f'Props of SystemResource: {other_props}') @@ -771,6 +1018,10 @@ def from_system_resource(system_resource: SystemResource, parent_node: Node) -> return new_system def to_system_resource(self) -> SystemResource: + """Render this `System` as a `SystemResource` pydantic model + suitable for POSTing to the server. Includes any attached + datastreams as ``outputs``. + """ resource = SystemResource(uid=self.urn, label=self.name, feature_type='PhysicalSystem') if len(self.datastreams) > 0: @@ -781,9 +1032,11 @@ def to_system_resource(self) -> SystemResource: 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, datarecord_schema: DataRecordSchema): @@ -876,6 +1129,10 @@ def add_and_insert_control_stream(self, control_stream_record_schema: DataRecord 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. + """ 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={ @@ -889,6 +1146,9 @@ def insert_self(self): print(f'Created system: {self._resource_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, @@ -901,20 +1161,27 @@ def retrieve_resource(self): self._underlying_resource = system_resource return None - def serialize(self) -> dict: - data = super().serialize() + 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["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] + 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.serialize() for cc in control_channels] + data["control_channels"] = [cc.to_storage_dict() for cc in control_channels] else: data["control_channels"] = None underlying = getattr(self, "_underlying_resource", None) @@ -933,7 +1200,18 @@ def serialize(self) -> dict: return data @classmethod - def deserialize(cls, data: dict, node: 'Node') -> 'System': + def from_storage_dict(cls, data: dict, node: 'Node') -> 'System': + """Build a `System` from a dict produced by `to_storage_dict`. + + Expects ``name``, ``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. + + :param data: Source dict. + :param node: Parent `Node` the rebuilt system attaches to. + """ obj = cls( name=data["name"], label=data["label"], @@ -943,14 +1221,24 @@ def deserialize(cls, data: dict, node: 'Node') -> 'System': 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", [])] + 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 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): @@ -958,21 +1246,31 @@ def __init__(self, parent_node: Node = None, datastream_resource: DatastreamReso self._underlying_resource = datastream_resource self._resource_id = datastream_resource.ds_id - def get_id(self): + 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): + def from_resource(ds_resource: DatastreamResource, parent_node: Node) -> 'Datastream': + """Build a `Datastream` from an already-parsed `DatastreamResource`.""" new_ds = Datastream(parent_node=parent_node, datastream_resource=ds_resource) return new_ds 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): + 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: @@ -980,6 +1278,10 @@ def create_observation(self, obs_data: dict): 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'}) @@ -991,6 +1293,10 @@ def insert_observation_dict(self, obs_data: dict): 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: @@ -1007,6 +1313,8 @@ def start(self): 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).""" super().init_mqtt() self._topic = self.get_mqtt_topic(subresource=APIResourceTypes.OBSERVATION, data_topic=True) @@ -1027,12 +1335,21 @@ def _queue_pop(self): return self._msg_reader_queue.get_nowait() def insert(self, data: dict): + """Encode ``data`` as JSON and publish it to this datastream's + observation MQTT topic. Bypasses the outbound deque.""" # self._queue_push(data) encoded = json.dumps(data).encode('utf-8') self._publish_mqtt(self._topic, encoded) - def serialize(self) -> dict: - data = super().serialize() + 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: @@ -1049,7 +1366,12 @@ def serialize(self) -> dict: return data @classmethod - def deserialize(cls, data: dict, node: 'Node') -> 'Datastream': + 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"]) @@ -1057,6 +1379,16 @@ def deserialize(cls, data: dict, node: 'Node') -> 'Datastream': 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: @@ -1073,6 +1405,19 @@ def subscribe(self, topic=None, callback=None, qos=0): 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 @@ -1087,13 +1432,16 @@ def __init__(self, node: Node = None, controlstream_resource: ControlStreamResou 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 init_mqtt(self): + """Set ``self._topic`` to the control stream's command data topic.""" super().init_mqtt() self._topic = self.get_mqtt_topic(subresource=APIResourceTypes.COMMAND, data_topic=True) - def get_mqtt_status_topic(self): + def get_mqtt_status_topic(self) -> str: + """Return the MQTT topic for command status updates (``:status``).""" return self.get_mqtt_topic(subresource=APIResourceTypes.STATUS, data_topic=True) def _emit_inbound_event(self, msg): @@ -1108,6 +1456,10 @@ def _emit_inbound_event(self, msg): 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: @@ -1124,22 +1476,28 @@ def start(self): logging.error("Error starting MQTT write task for %s: %s\n%s", self._id, e, traceback.format_exc()) - def get_inbound_deque(self): + def get_inbound_deque(self) -> deque: + """Return the deque receiving inbound command payloads.""" return self._inbound_deque - def get_outbound_deque(self): + def get_outbound_deque(self) -> deque: + """Return the deque feeding outbound command publishes.""" return self._outbound_deque - def get_status_deque_inbound(self): + 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): + 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, 'command')``.""" self.publish(payload, topic=APIResourceTypes.COMMAND.value) def publish_status(self, payload): + """Publish ``payload`` to the status MQTT topic. Convenience wrapper for ``publish(payload, 'status')``.""" self.publish(payload, topic=APIResourceTypes.STATUS.value) def publish(self, payload, topic: str = 'command'): @@ -1178,8 +1536,16 @@ def subscribe(self, topic=None, callback=None, qos=0): else: self._mqtt_client.subscribe(t, qos=qos, msg_callback=callback) - def serialize(self) -> dict: - data = super().serialize() + 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: @@ -1196,7 +1562,12 @@ def serialize(self) -> dict: return data @classmethod - def deserialize(cls, data: dict, node: 'Node') -> 'ControlStream': + 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"]) diff --git a/tests/test_node.py b/tests/test_node.py index e9369a9..104f352 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -15,10 +15,10 @@ def test_apihelper_url_generation(): assert helper.get_api_root_url(socket=True) == "wss://localhost:8282/sensorhub/api" -def test_node_password_round_trips_through_serialization(): +def test_node_password_round_trips_through_storage_dict(): 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' \ No newline at end of file + 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 From 8758df3610a2371bd5ace8ecd1674eda863e4a16 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 1 May 2026 22:39:07 -0500 Subject: [PATCH 04/29] Added format-explicit to_/from_smljson_dict, to_/from_omjson_dict, to_/from_swejson_dict, to_/from_geojson_dict, and to_/from_csapi_dict methods across System/Datastream/ControlStream and their underlying pydantic resource/schema models for round-tripping CS API server JSON, deprecated the older System.from_system_resource and Datastream.from_resource factories, and fixed three latent bugs (Node._client_session initialization, TimeUtils.time_to_iso UTC handling, ObservationOMJSONInline alias direction) exposed by the new tests. --- README.md | 35 ++++ pyproject.toml | 2 +- src/oshconnect/resource_datamodels.py | 175 +++++++++++++++- src/oshconnect/schema_datamodels.py | 73 ++++++- src/oshconnect/streamableresource.py | 276 ++++++++++++++++++++++++-- src/oshconnect/timemanagement.py | 2 +- uv.lock | 2 +- 7 files changed, 535 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 36aa365..02b1672 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,41 @@ uv run interrogate -vv src/oshconnect # per-symbol (shows which symbols 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 [MkDocs](https://www.mkdocs.org/) using the diff --git a/pyproject.toml b/pyproject.toml index 13f12ec..23feae1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "oshconnect" -version = "0.5.0a1" +version = "0.5.1a0" 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 = [ diff --git a/src/oshconnect/resource_datamodels.py b/src/oshconnect/resource_datamodels.py index 8262b9a..a18bd8d 100644 --- a/src/oshconnect/resource_datamodels.py +++ b/src/oshconnect/resource_datamodels.py @@ -6,7 +6,8 @@ # ============================================================================== 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 shapely import Point @@ -16,6 +17,9 @@ from .schema_datamodels import DatastreamRecordSchema, CommandSchema 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) @@ -132,6 +136,59 @@ class SystemResource(BaseModel): modes: List[Mode] = Field(None) method: ProcessMethod = Field(None) + def to_smljson_dict(self) -> dict: + """Render this system as an `application/sml+json` dict (SensorML JSON encoding). + + Sets ``feature_type = "PhysicalSystem"`` to match the SML discriminator + before dumping. Output keys are camelCase per the CS API wire format. + """ + self.feature_type = "PhysicalSystem" + return self.model_dump(by_alias=True, exclude_none=True, mode='json') + + 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. + + Sets ``feature_type = "Feature"`` to match the GeoJSON discriminator + before dumping. Useful when posting to endpoints that expect the + GeoJSON Feature shape. + """ + self.feature_type = "Feature" + return self.model_dump(by_alias=True, exclude_none=True, mode='json') + + 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): """ @@ -175,6 +232,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` / + `JSONDatastreamRecordSchema`) 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 +263,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) @@ -206,3 +360,22 @@ class ControlStreamResource(BaseModel): asynchronous: bool = Field(True, alias="async") command_schema: SerializeAsAny[CommandSchema] = 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/schema_datamodels.py b/src/oshconnect/schema_datamodels.py index a1ff338..d000710 100644 --- a/src/oshconnect/schema_datamodels.py +++ b/src/oshconnect/schema_datamodels.py @@ -17,6 +17,12 @@ from .geometry import Geometry from .swe_components import AnyComponent, check_named + +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 the burden on the end user to create those. @@ -33,6 +39,15 @@ class CommandJSON(BaseModel): sender: str = Field(None) params: Union[dict, list, int, float, str] = Field(None) + 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): """ @@ -58,6 +73,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): """ @@ -79,6 +103,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): """ @@ -111,6 +144,16 @@ 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 JSONDatastreamRecordSchema(DatastreamRecordSchema): """Datastream observation schema for the JSON media types @@ -144,19 +187,39 @@ def _root_schemas_require_name(self): check_named(self.parameters_schema, "JSONDatastreamRecordSchema.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) -> "JSONDatastreamRecordSchema": + """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 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): diff --git a/src/oshconnect/streamableresource.py b/src/oshconnect/streamableresource.py index 80f9709..e10de9a 100644 --- a/src/oshconnect/streamableresource.py +++ b/src/oshconnect/streamableresource.py @@ -48,6 +48,7 @@ import logging import traceback import uuid +import warnings from abc import ABC from dataclasses import dataclass, field from enum import Enum @@ -243,6 +244,8 @@ def __init__(self, protocol: str, address: str, port: int, 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) @@ -363,10 +366,12 @@ def register_streamable(self, streamable: StreamableResource): is driven by `OSHClientSession.connect_streamables` / `close_streamables`. - :raises ValueError: if the node was created without a SessionManager. + 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: - raise ValueError("Node is not registered with a SessionManager.") + return self._client_session.register_streamable(streamable) def get_session(self) -> OSHClientSession: @@ -992,30 +997,93 @@ def discover_controlstreams(self) -> list[ControlStream]: 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 name/uid under properties; SML form has + # label/uid directly on the resource. + if other_props.get('properties'): + props = other_props['properties'] + new_system = cls(name=props.get('name'), + label=props.get('name'), + urn=props.get('uid'), + resource_id=system_resource.system_id, parent_node=parent_node) + else: + new_system = cls(name=system_resource.label, + 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 + @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_csapi_dict` (auto-detect), + :meth:`System.from_smljson_dict`, or + :meth:`System.from_geojson_dict` instead. Those accept the raw + CS API dict directly without the manual `model_validate` step. + Handles both shapes the OSH server emits: the GeoJSON form (with a - ``properties`` block carrying ``name``/``uid``) and the flat form - (``name``/``label``/``urn`` directly on the resource). - """ - 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) + ``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_csapi_dict " + "(auto-detect), from_smljson_dict, or from_geojson_dict instead.", + DeprecationWarning, stacklevel=2, + ) + return System._construct_from_resource(system_resource, parent_node) - new_system.set_system_resource(system_resource) - return new_system + @classmethod + def from_smljson_dict(cls, data: dict, parent_node: Node) -> "System": + """Build a `System` from an `application/sml+json` dict (e.g., a + CS API server response body for a system in SML form).""" + resource = SystemResource.from_smljson_dict(data) + return cls._construct_from_resource(resource, parent_node) + + @classmethod + def from_geojson_dict(cls, data: dict, parent_node: Node) -> "System": + """Build a `System` from an `application/geo+json` dict (e.g., a + CS API server response body for a system in GeoJSON form).""" + resource = SystemResource.from_geojson_dict(data) + return cls._construct_from_resource(resource, parent_node) + + @classmethod + def from_csapi_dict(cls, data: dict, parent_node: Node) -> "System": + """Build a `System` from any CS API system dict, auto-dispatching on + the ``type`` field (``"PhysicalSystem"`` → SML+JSON, + ``"Feature"`` → GeoJSON, anything else → permissive validate).""" + resource = SystemResource.from_csapi_dict(data) + return cls._construct_from_resource(resource, parent_node) + + def to_smljson_dict(self) -> dict: + """Render this system as an `application/sml+json` dict + (SensorML JSON) ready to POST to a CS API ``/systems`` endpoint.""" + return self._underlying_resource.to_smljson_dict() if self._underlying_resource \ + else self.to_system_resource().to_smljson_dict() + + 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 + (GeoJSON Feature shape).""" + return self._underlying_resource.to_geojson_dict() if self._underlying_resource \ + else self.to_system_resource().to_geojson_dict() + + def to_geojson(self) -> str: + """JSON-string variant of `to_geojson_dict`.""" + return json.dumps(self.to_geojson_dict()) def to_system_resource(self) -> SystemResource: """Render this `System` as a `SystemResource` pydantic model @@ -1252,10 +1320,102 @@ def get_id(self) -> str: @staticmethod def from_resource(ds_resource: DatastreamResource, parent_node: Node) -> 'Datastream': - """Build a `Datastream` from an already-parsed `DatastreamResource`.""" + """Build a `Datastream` from an already-parsed `DatastreamResource`. + + .. deprecated:: 0.5.1 + Use :meth:`Datastream.from_csapi_dict` instead, which accepts + the raw CS API dict directly without the manual `model_validate` + step. + """ + warnings.warn( + "Datastream.from_resource is deprecated; use Datastream.from_csapi_dict instead.", + DeprecationWarning, stacklevel=2, + ) new_ds = Datastream(parent_node=parent_node, datastream_resource=ds_resource) return new_ds + @classmethod + def from_csapi_dict(cls, data: dict, parent_node: Node) -> "Datastream": + """Build a `Datastream` from a CS API datastream dict (e.g., a server + response body or an entry from a ``/datastreams`` listing).""" + ds_resource = DatastreamResource.from_csapi_dict(data) + return cls(parent_node=parent_node, datastream_resource=ds_resource) + + def to_csapi_dict(self) -> dict: + """Render this datastream as a CS API `application/json` resource + body (the same shape the server emits for ``/datastreams/{id}``). + + The embedded ``schema`` field carries whichever variant + (`SWEDatastreamRecordSchema` or `JSONDatastreamRecordSchema`) the + datastream was constructed with. + """ + return self._underlying_resource.to_csapi_dict() + + def to_csapi_json(self) -> str: + """JSON-string variant of `to_csapi_dict`.""" + return self._underlying_resource.to_csapi_json() + + def schema_to_swejson_dict(self) -> dict: + """Return the embedded record schema as an `application/swe+json` + document. Raises if the underlying schema is OM+JSON.""" + from .schema_datamodels import SWEDatastreamRecordSchema + rs = self._underlying_resource.record_schema + if not isinstance(rs, SWEDatastreamRecordSchema): + raise TypeError( + "Datastream is not configured with a SWE+JSON schema; " + f"got {type(rs).__name__}. Use schema_to_omjson_dict() instead." + ) + return rs.to_swejson_dict() + + def schema_to_omjson_dict(self) -> dict: + """Return the embedded record schema as an `application/om+json` + document. Raises if the underlying schema is SWE+JSON.""" + from .schema_datamodels import JSONDatastreamRecordSchema + rs = self._underlying_resource.record_schema + if not isinstance(rs, JSONDatastreamRecordSchema): + raise TypeError( + "Datastream is not configured with an OM+JSON schema; " + f"got {type(rs).__name__}. Use schema_to_swejson_dict() instead." + ) + return rs.to_omjson_dict() + + def observation_to_omjson_dict(self, obs: ObservationResource | dict) -> dict: + """Render a single observation as an `application/om+json` payload. + + :param obs: An `ObservationResource` or a result dict + (``create_observation`` will be used to wrap the latter). + """ + if isinstance(obs, dict): + obs = self.create_observation(obs) + return obs.to_omjson_dict(datastream_id=self._resource_id) + + def observation_to_swejson_dict(self, obs: ObservationResource | dict) -> dict: + """Render a single observation as an `application/swe+json` payload + (a flat record matching the schema's field names).""" + if isinstance(obs, dict): + obs = self.create_observation(obs) + schema = None + rs = getattr(self._underlying_resource, 'record_schema', None) + if rs is not None: + schema = getattr(rs, 'record_schema', None) + return obs.to_swejson_dict(schema=schema) + + @classmethod + def observation_from_omjson_dict(cls, data: dict) -> ObservationResource: + """Build an `ObservationResource` from an `application/om+json` dict.""" + return ObservationResource.from_omjson_dict(data) + + @classmethod + def observation_from_swejson_dict(cls, data: dict, schema=None, + result_time: str | None = None) -> ObservationResource: + """Build an `ObservationResource` from a SWE+JSON payload. + + :param data: The flat SWE+JSON record dict. + :param schema: Optional schema, currently advisory. + :param result_time: ISO 8601 timestamp; defaults to now. + """ + return ObservationResource.from_swejson_dict(data, schema=schema, result_time=result_time) + def set_resource(self, resource: DatastreamResource): """Replace the underlying `DatastreamResource` model.""" self._underlying_resource = resource @@ -1435,6 +1595,80 @@ def add_underlying_resource(self, resource: ControlStreamResource): """Replace the underlying `ControlStreamResource` model.""" self._underlying_resource = resource + @classmethod + def from_csapi_dict(cls, data: dict, parent_node: Node) -> "ControlStream": + """Build a `ControlStream` from a CS API control-stream dict (e.g., + a server response body or an entry from a ``/controlstreams`` + listing).""" + cs_resource = ControlStreamResource.from_csapi_dict(data) + return cls(node=parent_node, controlstream_resource=cs_resource) + + def to_csapi_dict(self) -> dict: + """Render this control stream as a CS API `application/json` + resource body. The embedded ``schema`` field carries whichever + variant (`SWEJSONCommandSchema` or `JSONCommandSchema`) the + control stream was constructed with. + """ + return self._underlying_resource.to_csapi_dict() + + def to_csapi_json(self) -> str: + """JSON-string variant of `to_csapi_dict`.""" + return self._underlying_resource.to_csapi_json() + + def schema_to_swejson_dict(self) -> dict: + """Return the embedded command schema as an `application/swe+json` + document. Raises if the underlying schema is JSON.""" + from .schema_datamodels import SWEJSONCommandSchema + cs = self._underlying_resource.command_schema + if not isinstance(cs, SWEJSONCommandSchema): + raise TypeError( + "ControlStream is not configured with a SWE+JSON schema; " + f"got {type(cs).__name__}. Use schema_to_json_dict() instead." + ) + return cs.to_swejson_dict() + + def schema_to_json_dict(self) -> dict: + """Return the embedded command schema as an `application/json` + document. Raises if the underlying schema is SWE+JSON.""" + cs = self._underlying_resource.command_schema + if not isinstance(cs, JSONCommandSchema): + raise TypeError( + "ControlStream is not configured with a JSON schema; " + f"got {type(cs).__name__}. Use schema_to_swejson_dict() instead." + ) + return cs.to_json_dict() + + def command_to_json_dict(self, payload: dict, sender: str | None = None) -> dict: + """Render a single command as an `application/json` payload + (the `CommandJSON` envelope: ``control@id``, ``issueTime``, + ``sender``, ``params``).""" + from .schema_datamodels import CommandJSON + cmd = CommandJSON( + control_id=self._resource_id, + sender=sender, + params=payload, + ) + return cmd.to_csapi_dict() + + def command_to_swejson_dict(self, payload: dict) -> dict: + """Render a single command as an `application/swe+json` payload + (a flat record matching the schema's field names).""" + return dict(payload) + + @classmethod + def command_from_json_dict(cls, data: dict): + """Build a `CommandJSON` from an `application/json` command dict.""" + from .schema_datamodels import CommandJSON + return CommandJSON.from_csapi_dict(data) + + @classmethod + def command_from_swejson_dict(cls, data: dict, schema=None) -> dict: + """Build a command params dict from a SWE+JSON payload. Schema is + accepted for forward compatibility (per-field type coercion); + currently a passthrough.""" + del schema + return dict(data) + def init_mqtt(self): """Set ``self._topic`` to the control stream's command data topic.""" super().init_mqtt() 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/uv.lock b/uv.lock index e1cc0e8..3e8d2cd 100644 --- a/uv.lock +++ b/uv.lock @@ -719,7 +719,7 @@ wheels = [ [[package]] name = "oshconnect" -version = "0.5.0a1" +version = "0.5.1a0" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, From db99d5d2190b5b5c38322f00b3ce0910cbe90740 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 1 May 2026 23:17:40 -0500 Subject: [PATCH 05/29] revert to sphinx docs and remove mkdocs related markdown from repo --- .github/workflows/docs_pages.yaml | 7 +- README.md | 43 ++- docs/markdown/api.md | 90 ------- docs/markdown/architecture.md | 93 ------- docs/markdown/index.md | 24 -- docs/markdown/tutorial.md | 208 -------------- docs/source/api.rst | 5 + docs/source/conf.py | 98 +++++-- docs/source/index.rst | 22 +- mkdocs.yml | 71 ----- pyproject.toml | 11 +- src/oshconnect/csapi4py/mqtt.py | 30 ++- src/oshconnect/streamableresource.py | 33 ++- uv.lock | 390 +++++++++------------------ 14 files changed, 284 insertions(+), 841 deletions(-) delete mode 100644 docs/markdown/api.md delete mode 100644 docs/markdown/architecture.md delete mode 100644 docs/markdown/index.md delete mode 100644 docs/markdown/tutorial.md delete mode 100644 mkdocs.yml diff --git a/.github/workflows/docs_pages.yaml b/.github/workflows/docs_pages.yaml index 90a84d3..3e2ea3a 100644 --- a/.github/workflows/docs_pages.yaml +++ b/.github/workflows/docs_pages.yaml @@ -24,14 +24,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/README.md b/README.md index 02b1672..c4ce847 100644 --- a/README.md +++ b/README.md @@ -100,48 +100,41 @@ version. ## Generating the Docs -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 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 MkDocs and plugins): +Install dev dependencies (including Sphinx, Furo, and the plugins): ```bash -uv sync +uv sync --all-extras ``` Build the HTML docs: ```bash -uv run mkdocs build +uv run sphinx-build -b html docs/source docs/build/sphinx ``` -The output will be in `docs/build/html/`. Open `docs/build/html/index.html` in -a browser to view locally. +Open `docs/build/sphinx/index.html` in a browser to view locally. -For a live-reloading preview while editing: +For a live-reloading preview while editing, use +[sphinx-autobuild](https://github.com/sphinx-doc/sphinx-autobuild): ```bash -uv run mkdocs serve +uv run --with sphinx-autobuild sphinx-autobuild docs/source docs/build/sphinx ``` -Then visit http://127.0.0.1:8000. - -To match what CI publishes (warnings become errors — useful when you've -touched docstrings): +To match what CI publishes (warnings become errors — useful after touching +docstrings or signatures): ```bash -uv run mkdocs build --strict +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`. - -The legacy Sphinx setup under `docs/source/` is kept temporarily for -reference and builds to a separate output directory: - -```bash -uv run sphinx-build -b html docs/source docs/build/sphinx -``` \ No newline at end of file +`.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/architecture.md b/docs/markdown/architecture.md deleted file mode 100644 index 98549f5..0000000 --- a/docs/markdown/architecture.md +++ /dev/null @@ -1,93 +0,0 @@ -# Architecture - -OSHConnect is structured around a small number of long-lived objects that mirror -the resource hierarchy of the OGC API – Connected Systems specification. - -## Object hierarchy - -```mermaid -graph TD - OSHConnect[OSHConnect
application entry point] - Node[Node
connection to one OSH server] - APIHelper[APIHelper
CS API HTTP requests] - Session[SessionManager
OSHClientSession instances] - MQTT[MQTTCommClient
paho-mqtt wrapper] - System[System
sensor system] - Datastream[Datastream
output channel — observations] - ControlStream[ControlStream
input channel — commands & status] - - OSHConnect --> Node - Node --> APIHelper - Node --> Session - Node --> MQTT - Node --> System - System --> Datastream - System --> ControlStream -``` - -## Key abstractions - -- **`OSHConnect`** (`oshconnectapi.py`) — top-level class. Owns nodes and - provides `discover_systems()`, `discover_datastreams()`, - `save_config()` / `load_config()`, and `create_and_insert_system()`. -- **`Node`** (`streamableresource.py`) — wraps a server connection. Drives - discovery via `APIHelper` and owns the `MQTTCommClient`. All HTTP resource - creation goes through here. -- **`StreamableResource`** (`streamableresource.py`) — abstract base for - `System`, `Datastream`, and `ControlStream`. Manages MQTT - subscriptions/publications, WebSocket connections, and the inbound / - outbound message deques. Connection modes: `PUSH`, `PULL`, `BIDIRECTIONAL`. -- **`Datastream` / `ControlStream`** (`streamableresource.py`) — concrete - streamable resources. Datastreams publish observations; ControlStreams - publish commands and receive status updates. Both follow CS API Part 3 - topic conventions (`:data`, `:status`, `:commands`). -- **`resource_datamodels.py`** — Pydantic models for the CS API resource types - (`SystemResource`, `DatastreamResource`, `ControlStreamResource`, - `ObservationResource`). These map directly to API request and response - bodies. -- **`swe_components.py`** — Pydantic models for SWE Common schema components - (`DataRecordSchema`, `QuantitySchema`, `VectorSchema`, etc.). Used to define - observation and command schemas when creating new datastreams. -- **`csapi4py/`** — sub-package that handles the CS API specifics: URL - construction (`endpoints.py`), request building (`con_sys_api.py`), enums - (`constants.py`), and MQTT topic conventions (`mqtt.py`). -- **`EventHandler`** (`eventbus.py`) — singleton pub/sub bus. Listeners - subscribe to event types (e.g. `NEW_OBSERVATION`) and topic strings; events - are dispatched asynchronously through an internal queue. -- **`timemanagement.py`** — `TimeInstant` (epoch / ISO-8601), `TimePeriod`, - `TemporalModes` (`REAL_TIME`, `ARCHIVE`, `BATCH`), and `TimeUtils` - conversions. - -## Typical data flow - -```mermaid -sequenceDiagram - autonumber - participant App as OSHConnect - participant N as Node - participant H as APIHelper - participant S as Server - participant DS as Datastream - - App->>N: add_node() - App->>N: discover_systems() - N->>H: retrieve_resource(SYSTEM) - H->>S: HTTP GET /systems - S-->>H: JSON - H-->>N: System objects - App->>DS: discover_datastreams() - DS->>DS: initialize() — open MQTT/WebSocket - DS->>DS: start() — begin streaming - S-->>DS: observations → _inbound_deque - Note over App,DS: To insert: resource.insert_self() →
APIHelper.create_resource() → POST →
server returns Location header with new ID -``` - -## Dependencies - -- **pydantic** — all resource and schema models. Bumping the minimum requires - confirming pre-built wheels exist for all supported Python versions - (3.12 – 3.14). -- **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 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/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..d5a0e6b 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,20 +1,20 @@ -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 - 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 +23,14 @@ Lower-level CS API utilities are available from ``oshconnect.csapi4py``. :maxdepth: 2 :caption: Contents + architecture tutorial api - Indices and tables ================== * :ref:`genindex` * :ref:`modindex` -* :ref:`search` \ No newline at end of file +* :ref:`search` 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 23feae1..d46fc26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "0.5.1a0" 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 = [ @@ -21,10 +21,13 @@ dev = [ "pytest>=8.3.5", "pytest-cov>=5.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>=7.4.7", - "sphinx-rtd-theme>=2.0.0", - "mkdocs-material>=9.5.0", - "mkdocstrings[python]>=0.26.0", + "furo>=2024.8.6", + "myst-parser>=4.0.0", + "sphinxcontrib-mermaid>=1.0.0", + "sphinx-copybutton>=0.5.2", ] tinydb = ["tinydb>=4.8.0,<5.0.0"] diff --git a/src/oshconnect/csapi4py/mqtt.py b/src/oshconnect/csapi4py/mqtt.py index 69f2bd0..de2a15a 100644 --- a/src/oshconnect/csapi4py/mqtt.py +++ b/src/oshconnect/csapi4py/mqtt.py @@ -7,20 +7,24 @@ 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/streamableresource.py b/src/oshconnect/streamableresource.py index e10de9a..27f3f56 100644 --- a/src/oshconnect/streamableresource.py +++ b/src/oshconnect/streamableresource.py @@ -1108,11 +1108,14 @@ 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. + """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)}') @@ -1151,14 +1154,18 @@ def add_insert_datastream(self, datarecord_schema: DataRecordSchema): 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 + """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 schema label is + lowercased and whitespace-stripped. + :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( ' ', '') diff --git a/uv.lock b/uv.lock index 3e8d2cd..c8f4a14 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" @@ -112,16 +124,16 @@ wheels = [ ] [[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]] @@ -275,11 +287,11 @@ wheels = [ [[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]] @@ -357,24 +369,19 @@ wheels = [ ] [[package]] -name = "ghp-import" -version = "2.1.0" +name = "furo" +version = "2025.12.19" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "python-dateutil" }, + { name = "accessible-pygments" }, + { name = "beautifulsoup4" }, + { name = "pygments" }, + { name = "sphinx" }, + { name = "sphinx-basic-ng" }, ] -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" } +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/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" }, -] - -[[package]] -name = "griffelib" -version = "2.0.2" -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" } -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]] @@ -433,12 +440,15 @@ wheels = [ ] [[package]] -name = "markdown" -version = "3.10.2" +name = "markdown-it-py" +version = "4.0.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/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } 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/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] [[package]] @@ -489,131 +499,24 @@ 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" +name = "mdit-py-plugins" +version = "0.5.0" 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" }, + { name = "markdown-it-py" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, ] [[package]] -name = "mkdocs-autorefs" -version = "1.4.4" +name = "mdurl" +version = "0.1.2" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown" }, - { name = "markupsafe" }, - { name = "mkdocs" }, -] -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" } -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" }, -] - -[[package]] -name = "mkdocs-get-deps" -version = "0.2.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" }, -] - -[[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" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { 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" }, -] -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" } -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" }, + { 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]] @@ -679,6 +582,23 @@ wheels = [ { 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" }, ] +[[package]] +name = "myst-parser" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "jinja2" }, + { name = "markdown-it-py" }, + { name = "mdit-py-plugins" }, + { name = "pyyaml" }, + { name = "sphinx" }, +] +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/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" @@ -733,13 +653,14 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "flake8" }, + { name = "furo" }, { name = "interrogate" }, - { name = "mkdocs-material" }, - { name = "mkdocstrings", extra = ["python"] }, + { name = "myst-parser" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "sphinx" }, - { name = "sphinx-rtd-theme" }, + { name = "sphinx-copybutton" }, + { name = "sphinxcontrib-mermaid" }, ] tinydb = [ { name = "tinydb" }, @@ -749,9 +670,9 @@ tinydb = [ requires-dist = [ { name = "aiohttp", specifier = ">=3.12.15" }, { name = "flake8", marker = "extra == 'dev'", specifier = ">=7.2.0" }, + { name = "furo", marker = "extra == 'dev'", specifier = ">=2024.8.6" }, { name = "interrogate", marker = "extra == 'dev'", specifier = ">=1.7.0" }, - { name = "mkdocs-material", marker = "extra == 'dev'", specifier = ">=9.5.0" }, - { name = "mkdocstrings", extras = ["python"], marker = "extra == 'dev'", specifier = ">=0.26.0" }, + { name = "myst-parser", marker = "extra == 'dev'", specifier = ">=4.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" }, @@ -759,7 +680,8 @@ requires-dist = [ { name = "requests" }, { 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 = "sphinx-copybutton", marker = "extra == 'dev'", specifier = ">=0.5.2" }, + { name = "sphinxcontrib-mermaid", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "tinydb", marker = "extra == 'tinydb'", specifier = ">=4.8.0,<5.0.0" }, { name = "websockets", specifier = ">=12.0,<16.0" }, ] @@ -774,15 +696,6 @@ 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" -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" } -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" }, -] - [[package]] name = "paho-mqtt" version = "2.1.0" @@ -792,24 +705,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" }, ] -[[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" }, -] - [[package]] name = "pluggy" version = "1.5.0" @@ -998,19 +893,6 @@ 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" -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" } -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" }, -] - [[package]] name = "pytest" version = "8.3.5" @@ -1040,18 +922,6 @@ wheels = [ { 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]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - [[package]] name = "pyyaml" version = "6.0.3" @@ -1098,18 +968,6 @@ 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" @@ -1125,6 +983,15 @@ 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" }, ] +[[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]] name = "shapely" version = "2.1.2" @@ -1177,26 +1044,26 @@ wheels = [ ] [[package]] -name = "six" -version = "1.17.0" +name = "snowballstemmer" +version = "2.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +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" } 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/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" }, ] [[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" }, @@ -1208,6 +1075,7 @@ dependencies = [ { name = "packaging" }, { name = "pygments" }, { name = "requests" }, + { name = "roman-numerals" }, { name = "snowballstemmer" }, { name = "sphinxcontrib-applehelp" }, { name = "sphinxcontrib-devhelp" }, @@ -1216,23 +1084,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 = "sphinx" }, +] +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/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 = "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/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/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/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]] @@ -1263,24 +1141,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.1" 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/2b/ae/999891de292919b66ea34f2c22fc22c9be90ab3536fbc0fca95716277351/sphinxcontrib_mermaid-2.0.1.tar.gz", hash = "sha256:a21a385a059a6cafd192aa3a586b14bf5c42721e229db67b459dc825d7f0a497", size = 19839, upload-time = "2026-03-05T14:10:41.901Z" } 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/03/46/25d64bcd7821c8d6f1080e1c43d5fcdfc442a18f759a230b5ccdc891093e/sphinxcontrib_mermaid-2.0.1-py3-none-any.whl", hash = "sha256:9dca7fbe827bad5e7e2b97c4047682cfd26e3e07398cfdc96c7a8842ae7f06e7", size = 14064, upload-time = "2026-03-05T14:10:40.533Z" }, ] [[package]] @@ -1349,30 +1229,6 @@ 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" }, -] - [[package]] name = "websockets" version = "12.0" From b4f169efa07eacfd1f8bae9b299d80b46e7ca7d6 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 1 May 2026 23:21:08 -0500 Subject: [PATCH 06/29] fix a flake8 linting error, add overlooked test_csapi_serialization.py file to git --- scripts/publish-local.py | 4 +- tests/test_csapi_serialization.py | 336 ++++++++++++++++++++++++++++++ 2 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 tests/test_csapi_serialization.py 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/tests/test_csapi_serialization.py b/tests/test_csapi_serialization.py new file mode 100644 index 0000000..199169f --- /dev/null +++ b/tests/test_csapi_serialization.py @@ -0,0 +1,336 @@ +"""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, + JSONDatastreamRecordSchema, + 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_wrapper_from_smljson_dict_builds_attached_to_node(node): + raw = json.loads((FIXTURES_DIR / "fake_weather_system_smljson.json").read_text()) + sys = System.from_smljson_dict(raw, node) + assert isinstance(sys, System) + assert sys.urn == "urn:osh:sensor:fakeweather:001" + assert sys.get_parent_node() is node + + +def test_system_wrapper_from_csapi_dict_dispatches_on_type(node): + raw_sml = json.loads((FIXTURES_DIR / "fake_weather_system_smljson.json").read_text()) + raw_geo = {"type": "Feature", "id": "geo-1", + "properties": {"name": "GeoSys", "uid": "urn:test:geo"}} + sys_sml = System.from_csapi_dict(raw_sml, node) + sys_geo = System.from_csapi_dict(raw_geo, node) + assert sys_sml.urn == "urn:osh:sensor:fakeweather:001" + assert sys_geo.urn == "urn:test:geo" + + +# =========================================================================== +# 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_to_swejson_dict_matches_fixture(node): + raw = json.loads((FIXTURES_DIR / "fake_weather_schema_swejson.json").read_text()) + schema = SWEDatastreamRecordSchema.from_swejson_dict(raw) + ds_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, + ) + ds = Datastream(parent_node=node, datastream_resource=ds_resource) + out = ds.schema_to_swejson_dict() + assert out["obsFormat"] == "application/swe+json" + assert out["recordSchema"]["name"] == "weather" + + +def test_datastream_schema_to_omjson_dict_matches_fixture(node): + raw = json.loads((FIXTURES_DIR / "fake_weather_schema_omjson.json").read_text()) + schema = JSONDatastreamRecordSchema.from_omjson_dict(raw) + ds_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, + ) + ds = Datastream(parent_node=node, datastream_resource=ds_resource) + out = ds.schema_to_omjson_dict() + assert out["obsFormat"] == "application/om+json" + assert out["resultSchema"]["name"] == "weather" + + +def test_datastream_schema_methods_reject_wrong_variant(node): + 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, + )) + with pytest.raises(TypeError, match="OM\\+JSON"): + ds.schema_to_omjson_dict() + + +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_datastream_observation_methods_attach_datastream_id(node): + ds_resource = DatastreamResource( + ds_id="ds-99", name="w", + valid_time=TimePeriod(start="2025-01-01T00:00:00Z", + end="2099-12-31T00:00:00Z"), + ) + ds = Datastream(parent_node=node, datastream_resource=ds_resource) + payload = ds.observation_to_omjson_dict({"temperature": 22.5}) + assert payload["datastream@id"] == "ds-99" + + +# =========================================================================== +# 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_to_json_dict(node): + cs_resource = _controlstream_resource_with_json_schema() + cs = ControlStream(node=node, controlstream_resource=cs_resource) + out = cs.schema_to_json_dict() + assert out["commandFormat"] == "application/json" + assert out["parametersSchema"]["name"] == "params" + + +def test_controlstream_schema_methods_reject_wrong_variant(node): + cs_resource = _controlstream_resource_with_json_schema() + cs = ControlStream(node=node, controlstream_resource=cs_resource) + with pytest.raises(TypeError, match="SWE\\+JSON"): + cs.schema_to_swejson_dict() + + +def test_controlstream_command_to_json_dict(node): + cs_resource = _controlstream_resource_with_json_schema() + cs = ControlStream(node=node, controlstream_resource=cs_resource) + out = cs.command_to_json_dict({"speed": 1.5}, sender="tester") + assert out["control@id"] == "cs-001" + assert out["sender"] == "tester" + assert out["params"] == {"speed": 1.5} + + +def test_controlstream_command_to_swejson_round_trips(node): + cs_resource = _controlstream_resource_with_json_schema() + cs = ControlStream(node=node, controlstream_resource=cs_resource) + payload = cs.command_to_swejson_dict({"speed": 1.5}) + assert payload == {"speed": 1.5} + rebuilt = ControlStream.command_from_swejson_dict(payload) + assert rebuilt == payload + + +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" + 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_csapi_dict"): + 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="from_csapi_dict"): + ds = Datastream.from_resource(ds_resource, node) + assert ds.get_id() == "ds-1" From 2da959ea2fbad161e90f931742b62d3bec52683e Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 1 May 2026 23:31:16 -0500 Subject: [PATCH 07/29] add actions for publishing dev versions to test.pypi --- .github/workflows/publish-test.yml | 74 +++++++++++++++++++ .github/workflows/tests.yaml | 15 +++- README.md | 16 ++++ .../fixtures/fake_weather_system_smljson.json | 8 ++ 4 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/publish-test.yml create mode 100644 tests/fixtures/fake_weather_system_smljson.json diff --git a/.github/workflows/publish-test.yml b/.github/workflows/publish-test.yml new file mode 100644 index 0000000..a42ebff --- /dev/null +++ b/.github/workflows/publish-test.yml @@ -0,0 +1,74 @@ +name: Publish (TestPyPI) + +# Fire only after the Tests workflow finishes. The job-level `if` further +# restricts to successful runs on the `dev` branch. +# +# One-time setup required at https://test.pypi.org/manage/account/publishing/ +# Owner: Botts-Innovative-Research +# Project: oshconnect +# Workflow: publish-test.yml +# Environment: publish-test +# And in this repo's Settings -> Environments, create an env named +# `publish-test` (no secrets needed; OIDC handles trust). +on: + workflow_run: + workflows: ["Tests"] + types: [completed] + +permissions: {} + +jobs: + publish: + if: > + github.event.workflow_run.conclusion == 'success' + && github.event.workflow_run.head_branch == '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 (matching the tested commit) + uses: actions/checkout@v5 + with: + ref: ${{ github.event.workflow_run.head_sha }} + + - 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 is in-memory on the runner; + # nothing is committed back to the repo. + - 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/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 8989ea8..23efd84 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -1,5 +1,18 @@ name: Tests -on: [ push, pull_request, workflow_dispatch ] +on: + push: + paths-ignore: + - 'docs/**' + - 'README.md' + - 'CLAUDE.md' + - '.github/workflows/docs_pages.yaml' + pull_request: + paths-ignore: + - 'docs/**' + - 'README.md' + - 'CLAUDE.md' + - '.github/workflows/docs_pages.yaml' + workflow_dispatch: permissions: {} diff --git a/README.md b/README.md index c4ce847..1fc3273 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,22 @@ 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) +## 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 `--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`. + ## Running Tests ```bash 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" +} From 5f2f289edd286b0a3e8e32c490cb96dc185e822a Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 1 May 2026 23:45:59 -0500 Subject: [PATCH 08/29] correct behavior for publishing dev builds to test.pypi. add test dependency to main publish workflow so we are less likely to publish a broken build --- .github/workflows/publish-test.yml | 74 ------------------------------ .github/workflows/publish.yml | 52 ++++++++++++++++++--- .github/workflows/tests.yaml | 68 ++++++++++++++++++++++++++- 3 files changed, 113 insertions(+), 81 deletions(-) delete mode 100644 .github/workflows/publish-test.yml diff --git a/.github/workflows/publish-test.yml b/.github/workflows/publish-test.yml deleted file mode 100644 index a42ebff..0000000 --- a/.github/workflows/publish-test.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Publish (TestPyPI) - -# Fire only after the Tests workflow finishes. The job-level `if` further -# restricts to successful runs on the `dev` branch. -# -# One-time setup required at https://test.pypi.org/manage/account/publishing/ -# Owner: Botts-Innovative-Research -# Project: oshconnect -# Workflow: publish-test.yml -# Environment: publish-test -# And in this repo's Settings -> Environments, create an env named -# `publish-test` (no secrets needed; OIDC handles trust). -on: - workflow_run: - workflows: ["Tests"] - types: [completed] - -permissions: {} - -jobs: - publish: - if: > - github.event.workflow_run.conclusion == 'success' - && github.event.workflow_run.head_branch == '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 (matching the tested commit) - uses: actions/checkout@v5 - with: - ref: ${{ github.event.workflow_run.head_sha }} - - - 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 is in-memory on the runner; - # nothing is committed back to the repo. - - 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/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6e2ee91..e8a84f4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,27 +1,67 @@ -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 +# passing on the tagged commit — we don't ship a release that fails CI. 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" + + publish: + needs: tests 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 index 23efd84..7947e58 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -1,6 +1,10 @@ name: Tests on: push: + # 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: + - '**' paths-ignore: - 'docs/**' - 'README.md' @@ -61,4 +65,66 @@ jobs: name: coverage-${{ matrix.python-version }} path: coverage.xml if-no-files-found: warn - retention-days: 7 \ No newline at end of file + retention-days: 7 + + # Publish a `.devN` pre-release wheel to TestPyPI on every push to dev, + # gated on the full pytest matrix 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 + 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/ From 68de3b0f80a4ebc0dd6827f48766c7729e8e7bd3 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 1 May 2026 23:58:46 -0500 Subject: [PATCH 09/29] prevent this workflow from running on branches besides main and dev --- .github/workflows/docs_pages.yaml | 5 +++- .github/workflows/publish.yml | 29 ++++++++++++++++-- .github/workflows/tests.yaml | 50 ++++++++++++++++++++++++++----- 3 files changed, 73 insertions(+), 11 deletions(-) diff --git a/.github/workflows/docs_pages.yaml b/.github/workflows/docs_pages.yaml index 3e2ea3a..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: {} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e8a84f4..f242ba5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,8 +1,9 @@ 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 -# passing on the tagged commit — we don't ship a release that fails CI. +# 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: @@ -42,8 +43,30 @@ jobs: 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 + needs: [tests, docs] runs-on: ubuntu-latest environment: name: publish diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 7947e58..9610c87 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -5,14 +5,14 @@ on: # 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: - - 'docs/**' - 'README.md' - 'CLAUDE.md' - '.github/workflows/docs_pages.yaml' pull_request: paths-ignore: - - 'docs/**' - 'README.md' - 'CLAUDE.md' - '.github/workflows/docs_pages.yaml' @@ -67,11 +67,47 @@ jobs: 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 the full pytest matrix 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. + # 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 @@ -80,7 +116,7 @@ jobs: # Environment: publish-test # And in this repo's Settings -> Environments, create env `publish-test`. publish-test: - needs: pytest + needs: [pytest, docs] if: github.event_name == 'push' && github.ref == 'refs/heads/dev' runs-on: ubuntu-latest environment: From ddc971ab57596bc9a7351574003830b2ade8d5db Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Sat, 2 May 2026 00:05:03 -0500 Subject: [PATCH 10/29] add branches wildcard to fix issue around having tags denied disallowing the workflow to execute on pushes --- .github/workflows/tests.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 9610c87..a50e299 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -1,6 +1,8 @@ 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: From be1b51b221ff4af6bbf57b1e5cf54e0da1a38072 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Sat, 2 May 2026 00:12:49 -0500 Subject: [PATCH 11/29] commit missing architecture.md file --- docs/source/architecture.md | 93 +++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 docs/source/architecture.md diff --git a/docs/source/architecture.md b/docs/source/architecture.md new file mode 100644 index 0000000..98549f5 --- /dev/null +++ b/docs/source/architecture.md @@ -0,0 +1,93 @@ +# Architecture + +OSHConnect is structured around a small number of long-lived objects that mirror +the resource hierarchy of the OGC API – Connected Systems specification. + +## Object hierarchy + +```mermaid +graph TD + OSHConnect[OSHConnect
application entry point] + Node[Node
connection to one OSH server] + APIHelper[APIHelper
CS API HTTP requests] + Session[SessionManager
OSHClientSession instances] + MQTT[MQTTCommClient
paho-mqtt wrapper] + System[System
sensor system] + Datastream[Datastream
output channel — observations] + ControlStream[ControlStream
input channel — commands & status] + + OSHConnect --> Node + Node --> APIHelper + Node --> Session + Node --> MQTT + Node --> System + System --> Datastream + System --> ControlStream +``` + +## Key abstractions + +- **`OSHConnect`** (`oshconnectapi.py`) — top-level class. Owns nodes and + provides `discover_systems()`, `discover_datastreams()`, + `save_config()` / `load_config()`, and `create_and_insert_system()`. +- **`Node`** (`streamableresource.py`) — wraps a server connection. Drives + discovery via `APIHelper` and owns the `MQTTCommClient`. All HTTP resource + creation goes through here. +- **`StreamableResource`** (`streamableresource.py`) — abstract base for + `System`, `Datastream`, and `ControlStream`. Manages MQTT + subscriptions/publications, WebSocket connections, and the inbound / + outbound message deques. Connection modes: `PUSH`, `PULL`, `BIDIRECTIONAL`. +- **`Datastream` / `ControlStream`** (`streamableresource.py`) — concrete + streamable resources. Datastreams publish observations; ControlStreams + publish commands and receive status updates. Both follow CS API Part 3 + topic conventions (`:data`, `:status`, `:commands`). +- **`resource_datamodels.py`** — Pydantic models for the CS API resource types + (`SystemResource`, `DatastreamResource`, `ControlStreamResource`, + `ObservationResource`). These map directly to API request and response + bodies. +- **`swe_components.py`** — Pydantic models for SWE Common schema components + (`DataRecordSchema`, `QuantitySchema`, `VectorSchema`, etc.). Used to define + observation and command schemas when creating new datastreams. +- **`csapi4py/`** — sub-package that handles the CS API specifics: URL + construction (`endpoints.py`), request building (`con_sys_api.py`), enums + (`constants.py`), and MQTT topic conventions (`mqtt.py`). +- **`EventHandler`** (`eventbus.py`) — singleton pub/sub bus. Listeners + subscribe to event types (e.g. `NEW_OBSERVATION`) and topic strings; events + are dispatched asynchronously through an internal queue. +- **`timemanagement.py`** — `TimeInstant` (epoch / ISO-8601), `TimePeriod`, + `TemporalModes` (`REAL_TIME`, `ARCHIVE`, `BATCH`), and `TimeUtils` + conversions. + +## Typical data flow + +```mermaid +sequenceDiagram + autonumber + participant App as OSHConnect + participant N as Node + participant H as APIHelper + participant S as Server + participant DS as Datastream + + App->>N: add_node() + App->>N: discover_systems() + N->>H: retrieve_resource(SYSTEM) + H->>S: HTTP GET /systems + S-->>H: JSON + H-->>N: System objects + App->>DS: discover_datastreams() + DS->>DS: initialize() — open MQTT/WebSocket + DS->>DS: start() — begin streaming + S-->>DS: observations → _inbound_deque + Note over App,DS: To insert: resource.insert_self() →
APIHelper.create_resource() → POST →
server returns Location header with new ID +``` + +## Dependencies + +- **pydantic** — all resource and schema models. Bumping the minimum requires + confirming pre-built wheels exist for all supported Python versions + (3.12 – 3.14). +- **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 From c71489951fab96fc038e59e0c0cadcaedeb841db Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Tue, 5 May 2026 15:49:23 -0500 Subject: [PATCH 12/29] remove serialization methods from streamable resources that were confused with underlying object representations that are needed for actual wire serialization. Update docs to reflect this. --- docs/source/architecture/class_hierarchy.md | 284 ++++ docs/source/architecture/construction.md | 252 ++++ docs/source/architecture/events.md | 173 +++ .../index.md} | 19 +- docs/source/architecture/insertion.md | 114 ++ docs/source/architecture/serialization.md | 150 ++ docs/source/index.rst | 2 +- pyproject.toml | 17 +- src/oshconnect/schema_datamodels.py | 63 + src/oshconnect/streamableresource.py | 289 ++-- tests/test_csapi_serialization.py | 263 +++- uv.lock | 1283 ++++++++++------- 12 files changed, 2130 insertions(+), 779 deletions(-) create mode 100644 docs/source/architecture/class_hierarchy.md create mode 100644 docs/source/architecture/construction.md create mode 100644 docs/source/architecture/events.md rename docs/source/{architecture.md => architecture/index.md} (90%) create mode 100644 docs/source/architecture/insertion.md create mode 100644 docs/source/architecture/serialization.md diff --git a/docs/source/architecture/class_hierarchy.md b/docs/source/architecture/class_hierarchy.md new file mode 100644 index 0000000..9efa66e --- /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 JSONDatastreamRecordSchema { + +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 <|-- JSONDatastreamRecordSchema + 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..bfd468b --- /dev/null +++ b/docs/source/architecture/construction.md @@ -0,0 +1,252 @@ +# 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 + - `JSONDatastreamRecordSchema.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" + +`Datastream` has three dedicated fetch methods, one per `obsFormat` +the server supports. Each returns a typed schema model so there's no +runtime auto-dispatch: + +```python +ds = Datastream(parent_node=node, datastream_resource=DatastreamResource.from_csapi_dict(server_response)) + +# Wire-format schemas (CS API spec) +sw = ds.fetch_swejson_schema() # -> SWEDatastreamRecordSchema (application/swe+json) +om = ds.fetch_omjson_schema() # -> JSONDatastreamRecordSchema (application/om+json) + +# OSH-specific JSON Schema flavor +lg = ds.fetch_logical_schema() # -> LogicalDatastreamRecordSchema (obsFormat=logical) +``` + +Each method: + +1. Hits ``GET /datastreams/{id}/schema?obsFormat={format}`` using the + parent `Node`'s `APIHelper` for base URL + auth. +2. Parses the response into the corresponding pydantic model. +3. Returns the parsed model — does *not* mutate the datastream's + `_underlying_resource.record_schema`. If you want to cache it, do + it explicitly. + +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_new_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..188b172 --- /dev/null +++ b/docs/source/architecture/events.md @@ -0,0 +1,173 @@ +# 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; ControlStreams publish + commands. 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/source/architecture.md b/docs/source/architecture/index.md similarity index 90% rename from docs/source/architecture.md rename to docs/source/architecture/index.md index 98549f5..e07e036 100644 --- a/docs/source/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..86c0453 --- /dev/null +++ b/docs/source/architecture/insertion.md @@ -0,0 +1,114 @@ +# 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. Issues `requests.post(url, data=body, headers=req_headers, auth=self.auth)`. +3. 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..220ca4a --- /dev/null +++ b/docs/source/architecture/serialization.md @@ -0,0 +1,150 @@ +# 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: `JSONDatastreamRecordSchema.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). See [Construction → "I want the schema for an existing +datastream from the server"](construction.md) for the +`Datastream.fetch_logical_schema()` method that retrieves it. + +## 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/index.rst b/docs/source/index.rst index d5a0e6b..f2d86fc 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -23,7 +23,7 @@ Lower-level CS API utilities are available from ``oshconnect.csapi4py``. :maxdepth: 2 :caption: Contents - architecture + architecture/index tutorial api diff --git a/pyproject.toml b/pyproject.toml index d46fc26..48df1e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,14 +11,20 @@ dependencies = [ "paho-mqtt>=2.1.0", "pydantic>=2.12.5,<3.0.0", "shapely>=2.1.2,<3.0.0", - "websockets>=12.0,<16.0", - "requests", - "aiohttp>=3.12.15", + "websockets>=12.0,<17.0", + # Floors below resolve open Dependabot alerts (May 2026 sweep). See the + # security tab for the per-advisory list; collectively these fix 25 of 27. + "requests>=2.33.1", + "aiohttp>=3.13.5", + "urllib3>=2.6.3", # transitive via requests; explicit floor pins the patched version ] [project.optional-dependencies] dev = [ "flake8>=7.2.0", - "pytest>=8.3.5", + # pytest>=8.4.2 picks up the tmpdir handling fix (GHSA / Dependabot alert #27). + # 9.x verified compatible (May 2026): only PytestRemovedIn9Warning -> error + # could bite, and our suite uses none of those deprecated APIs. + "pytest>=8.4.2", "pytest-cov>=5.0.0", "interrogate>=1.7.0", # Sphinx + Furo is the canonical docs toolchain. Furo is the modern @@ -28,6 +34,9 @@ dev = [ "myst-parser>=4.0.0", "sphinxcontrib-mermaid>=1.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"] diff --git a/src/oshconnect/schema_datamodels.py b/src/oshconnect/schema_datamodels.py index d000710..b3a8e25 100644 --- a/src/oshconnect/schema_datamodels.py +++ b/src/oshconnect/schema_datamodels.py @@ -199,6 +199,69 @@ def from_omjson_dict(cls, data: dict) -> "JSONDatastreamRecordSchema": 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 `JSONDatastreamRecordSchema`: + + - 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 diff --git a/src/oshconnect/streamableresource.py b/src/oshconnect/streamableresource.py index 27f3f56..a5b60c8 100644 --- a/src/oshconnect/streamableresource.py +++ b/src/oshconnect/streamableresource.py @@ -49,6 +49,8 @@ import traceback import uuid import warnings + +import requests from abc import ABC from dataclasses import dataclass, field from enum import Enum @@ -1022,81 +1024,58 @@ def _construct_from_resource(cls, system_resource: SystemResource, 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_csapi_dict` (auto-detect), - :meth:`System.from_smljson_dict`, or - :meth:`System.from_geojson_dict` instead. Those accept the raw - CS API dict directly without the manual `model_validate` step. + 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_csapi_dict " - "(auto-detect), from_smljson_dict, or from_geojson_dict instead.", + "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) - @classmethod - def from_smljson_dict(cls, data: dict, parent_node: Node) -> "System": - """Build a `System` from an `application/sml+json` dict (e.g., a - CS API server response body for a system in SML form).""" - resource = SystemResource.from_smljson_dict(data) - return cls._construct_from_resource(resource, parent_node) - - @classmethod - def from_geojson_dict(cls, data: dict, parent_node: Node) -> "System": - """Build a `System` from an `application/geo+json` dict (e.g., a - CS API server response body for a system in GeoJSON form).""" - resource = SystemResource.from_geojson_dict(data) - return cls._construct_from_resource(resource, parent_node) - - @classmethod - def from_csapi_dict(cls, data: dict, parent_node: Node) -> "System": - """Build a `System` from any CS API system dict, auto-dispatching on - the ``type`` field (``"PhysicalSystem"`` → SML+JSON, - ``"Feature"`` → GeoJSON, anything else → permissive validate).""" - resource = SystemResource.from_csapi_dict(data) - return cls._construct_from_resource(resource, parent_node) - - def to_smljson_dict(self) -> dict: - """Render this system as an `application/sml+json` dict - (SensorML JSON) ready to POST to a CS API ``/systems`` endpoint.""" - return self._underlying_resource.to_smljson_dict() if self._underlying_resource \ - else self.to_system_resource().to_smljson_dict() - - 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 - (GeoJSON Feature shape).""" - return self._underlying_resource.to_geojson_dict() if self._underlying_resource \ - else self.to_system_resource().to_geojson_dict() - - def to_geojson(self) -> str: - """JSON-string variant of `to_geojson_dict`.""" - return json.dumps(self.to_geojson_dict()) - def to_system_resource(self) -> SystemResource: """Render this `System` as a `SystemResource` pydantic model - suitable for POSTing to the server. Includes any attached - datastreams as ``outputs``. + suitable for POSTing to the server. Wrapper-specific: assembles + attached datastreams into the resource's ``outputs`` list. """ resource = SystemResource(uid=self.urn, label=self.name, feature_type='PhysicalSystem') - - if len(self.datastreams) > 0: + if self.datastreams: 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): @@ -1330,98 +1309,68 @@ def from_resource(ds_resource: DatastreamResource, parent_node: Node) -> 'Datast """Build a `Datastream` from an already-parsed `DatastreamResource`. .. deprecated:: 0.5.1 - Use :meth:`Datastream.from_csapi_dict` instead, which accepts - the raw CS API dict directly without the manual `model_validate` - step. + 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; use Datastream.from_csapi_dict instead.", + "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, ) - new_ds = Datastream(parent_node=parent_node, datastream_resource=ds_resource) - return new_ds + return Datastream(parent_node=parent_node, datastream_resource=ds_resource) - @classmethod - def from_csapi_dict(cls, data: dict, parent_node: Node) -> "Datastream": - """Build a `Datastream` from a CS API datastream dict (e.g., a server - response body or an entry from a ``/datastreams`` listing).""" - ds_resource = DatastreamResource.from_csapi_dict(data) - return cls(parent_node=parent_node, datastream_resource=ds_resource) + # ------------------------------------------------------------------ + # Schema retrieval from CS API server (GET /datastreams/{id}/schema) + # ------------------------------------------------------------------ - def to_csapi_dict(self) -> dict: - """Render this datastream as a CS API `application/json` resource - body (the same shape the server emits for ``/datastreams/{id}``). - - The embedded ``schema`` field carries whichever variant - (`SWEDatastreamRecordSchema` or `JSONDatastreamRecordSchema`) the - datastream was constructed with. + def _fetch_schema_dict(self, obs_format: str) -> dict: + """Internal: GET ``/datastreams/{id}/schema?obsFormat={obs_format}`` + through the parent node's APIHelper auth, return the JSON body. + Raises :class:`requests.HTTPError` on non-2xx responses. """ - return self._underlying_resource.to_csapi_dict() + api = self._parent_node.get_api_helper() + url = f"{api.get_api_root_url()}/datastreams/{self._resource_id}/schema" + resp = requests.get(url, params={"obsFormat": obs_format}, + auth=api.get_helper_auth()) + resp.raise_for_status() + return resp.json() - def to_csapi_json(self) -> str: - """JSON-string variant of `to_csapi_dict`.""" - return self._underlying_resource.to_csapi_json() + def fetch_swejson_schema(self): + """Fetch this datastream's schema in `application/swe+json` form + from the server, parsed into a `SWEDatastreamRecordSchema`. - def schema_to_swejson_dict(self) -> dict: - """Return the embedded record schema as an `application/swe+json` - document. Raises if the underlying schema is OM+JSON.""" + Hits ``GET /datastreams/{id}/schema?obsFormat=application/swe+json``. + Auth + base URL come from the parent `Node`'s `APIHelper`. + """ from .schema_datamodels import SWEDatastreamRecordSchema - rs = self._underlying_resource.record_schema - if not isinstance(rs, SWEDatastreamRecordSchema): - raise TypeError( - "Datastream is not configured with a SWE+JSON schema; " - f"got {type(rs).__name__}. Use schema_to_omjson_dict() instead." - ) - return rs.to_swejson_dict() - - def schema_to_omjson_dict(self) -> dict: - """Return the embedded record schema as an `application/om+json` - document. Raises if the underlying schema is SWE+JSON.""" - from .schema_datamodels import JSONDatastreamRecordSchema - rs = self._underlying_resource.record_schema - if not isinstance(rs, JSONDatastreamRecordSchema): - raise TypeError( - "Datastream is not configured with an OM+JSON schema; " - f"got {type(rs).__name__}. Use schema_to_swejson_dict() instead." - ) - return rs.to_omjson_dict() - - def observation_to_omjson_dict(self, obs: ObservationResource | dict) -> dict: - """Render a single observation as an `application/om+json` payload. - - :param obs: An `ObservationResource` or a result dict - (``create_observation`` will be used to wrap the latter). - """ - if isinstance(obs, dict): - obs = self.create_observation(obs) - return obs.to_omjson_dict(datastream_id=self._resource_id) - - def observation_to_swejson_dict(self, obs: ObservationResource | dict) -> dict: - """Render a single observation as an `application/swe+json` payload - (a flat record matching the schema's field names).""" - if isinstance(obs, dict): - obs = self.create_observation(obs) - schema = None - rs = getattr(self._underlying_resource, 'record_schema', None) - if rs is not None: - schema = getattr(rs, 'record_schema', None) - return obs.to_swejson_dict(schema=schema) + data = self._fetch_schema_dict(ObservationFormat.SWE_JSON.value) + return SWEDatastreamRecordSchema.from_swejson_dict(data) - @classmethod - def observation_from_omjson_dict(cls, data: dict) -> ObservationResource: - """Build an `ObservationResource` from an `application/om+json` dict.""" - return ObservationResource.from_omjson_dict(data) + def fetch_omjson_schema(self): + """Fetch this datastream's schema in `application/om+json` form + from the server, parsed into a `JSONDatastreamRecordSchema`. - @classmethod - def observation_from_swejson_dict(cls, data: dict, schema=None, - result_time: str | None = None) -> ObservationResource: - """Build an `ObservationResource` from a SWE+JSON payload. + Hits ``GET /datastreams/{id}/schema?obsFormat=application/om+json``. + """ + from .schema_datamodels import JSONDatastreamRecordSchema + data = self._fetch_schema_dict(ObservationFormat.JSON.value) + return JSONDatastreamRecordSchema.from_omjson_dict(data) + + def fetch_logical_schema(self): + """Fetch this datastream's schema in OSH's `obsFormat=logical` form + from the server, parsed into a `LogicalDatastreamRecordSchema`. - :param data: The flat SWE+JSON record dict. - :param schema: Optional schema, currently advisory. - :param result_time: ISO 8601 timestamp; defaults to now. + Hits ``GET /datastreams/{id}/schema?obsFormat=logical``. The + response is 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. OSH-specific — + not in the OGC CS API spec. """ - return ObservationResource.from_swejson_dict(data, schema=schema, result_time=result_time) + from .schema_datamodels import LogicalDatastreamRecordSchema + data = self._fetch_schema_dict("logical") + return LogicalDatastreamRecordSchema.from_logical_dict(data) def set_resource(self, resource: DatastreamResource): """Replace the underlying `DatastreamResource` model.""" @@ -1602,80 +1551,6 @@ def add_underlying_resource(self, resource: ControlStreamResource): """Replace the underlying `ControlStreamResource` model.""" self._underlying_resource = resource - @classmethod - def from_csapi_dict(cls, data: dict, parent_node: Node) -> "ControlStream": - """Build a `ControlStream` from a CS API control-stream dict (e.g., - a server response body or an entry from a ``/controlstreams`` - listing).""" - cs_resource = ControlStreamResource.from_csapi_dict(data) - return cls(node=parent_node, controlstream_resource=cs_resource) - - def to_csapi_dict(self) -> dict: - """Render this control stream as a CS API `application/json` - resource body. The embedded ``schema`` field carries whichever - variant (`SWEJSONCommandSchema` or `JSONCommandSchema`) the - control stream was constructed with. - """ - return self._underlying_resource.to_csapi_dict() - - def to_csapi_json(self) -> str: - """JSON-string variant of `to_csapi_dict`.""" - return self._underlying_resource.to_csapi_json() - - def schema_to_swejson_dict(self) -> dict: - """Return the embedded command schema as an `application/swe+json` - document. Raises if the underlying schema is JSON.""" - from .schema_datamodels import SWEJSONCommandSchema - cs = self._underlying_resource.command_schema - if not isinstance(cs, SWEJSONCommandSchema): - raise TypeError( - "ControlStream is not configured with a SWE+JSON schema; " - f"got {type(cs).__name__}. Use schema_to_json_dict() instead." - ) - return cs.to_swejson_dict() - - def schema_to_json_dict(self) -> dict: - """Return the embedded command schema as an `application/json` - document. Raises if the underlying schema is SWE+JSON.""" - cs = self._underlying_resource.command_schema - if not isinstance(cs, JSONCommandSchema): - raise TypeError( - "ControlStream is not configured with a JSON schema; " - f"got {type(cs).__name__}. Use schema_to_swejson_dict() instead." - ) - return cs.to_json_dict() - - def command_to_json_dict(self, payload: dict, sender: str | None = None) -> dict: - """Render a single command as an `application/json` payload - (the `CommandJSON` envelope: ``control@id``, ``issueTime``, - ``sender``, ``params``).""" - from .schema_datamodels import CommandJSON - cmd = CommandJSON( - control_id=self._resource_id, - sender=sender, - params=payload, - ) - return cmd.to_csapi_dict() - - def command_to_swejson_dict(self, payload: dict) -> dict: - """Render a single command as an `application/swe+json` payload - (a flat record matching the schema's field names).""" - return dict(payload) - - @classmethod - def command_from_json_dict(cls, data: dict): - """Build a `CommandJSON` from an `application/json` command dict.""" - from .schema_datamodels import CommandJSON - return CommandJSON.from_csapi_dict(data) - - @classmethod - def command_from_swejson_dict(cls, data: dict, schema=None) -> dict: - """Build a command params dict from a SWE+JSON payload. Schema is - accepted for forward compatibility (per-field type coercion); - currently a passthrough.""" - del schema - return dict(data) - def init_mqtt(self): """Set ``self._topic`` to the control stream's command data topic.""" super().init_mqtt() diff --git a/tests/test_csapi_serialization.py b/tests/test_csapi_serialization.py index 199169f..b62d7e1 100644 --- a/tests/test_csapi_serialization.py +++ b/tests/test_csapi_serialization.py @@ -31,6 +31,7 @@ CommandJSON, JSONCommandSchema, JSONDatastreamRecordSchema, + LogicalDatastreamRecordSchema, ObservationOMJSONInline, SWEDatastreamRecordSchema, SWEJSONCommandSchema, @@ -97,22 +98,55 @@ def test_system_smljson_fixture_round_trips(): assert key in re_dumped -def test_system_wrapper_from_smljson_dict_builds_attached_to_node(node): - raw = json.loads((FIXTURES_DIR / "fake_weather_system_smljson.json").read_text()) - sys = System.from_smljson_dict(raw, node) +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:osh:sensor:fakeweather:001" + 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.name == "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_wrapper_from_csapi_dict_dispatches_on_type(node): - raw_sml = json.loads((FIXTURES_DIR / "fake_weather_system_smljson.json").read_text()) - raw_geo = {"type": "Feature", "id": "geo-1", - "properties": {"name": "GeoSys", "uid": "urn:test:geo"}} - sys_sml = System.from_csapi_dict(raw_sml, node) - sys_geo = System.from_csapi_dict(raw_geo, node) - assert sys_sml.urn == "urn:osh:sensor:fakeweather:001" - assert sys_geo.urn == "urn:test:geo" +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.name == "GeoSys2" # =========================================================================== @@ -139,47 +173,161 @@ def test_datastream_resource_round_trips(): assert rebuilt.ds_id == "ds-001" -def test_datastream_schema_to_swejson_dict_matches_fixture(node): +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_resource = DatastreamResource( + 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, - ) - ds = Datastream(parent_node=node, datastream_resource=ds_resource) - out = ds.schema_to_swejson_dict() + )) + out = ds._underlying_resource.record_schema.to_swejson_dict() assert out["obsFormat"] == "application/swe+json" assert out["recordSchema"]["name"] == "weather" -def test_datastream_schema_to_omjson_dict_matches_fixture(node): - raw = json.loads((FIXTURES_DIR / "fake_weather_schema_omjson.json").read_text()) - schema = JSONDatastreamRecordSchema.from_omjson_dict(raw) +# --------------------------------------------------------------------------- +# 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_datastream_fetch_logical_schema_hits_correct_endpoint(node, monkeypatch): + """Mock `requests.get` and verify `fetch_logical_schema()` constructs + the right URL + query param + auth, and routes the response through + `LogicalDatastreamRecordSchema`.""" + 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, auth=None, **kwargs): + captured["url"] = url + captured["params"] = params + captured["auth"] = auth + return _MockResponse() + + monkeypatch.setattr("oshconnect.streamableresource.requests.get", _mock_get) + ds_resource = DatastreamResource( - ds_id="ds-1", name="w", + ds_id="038s1ic7k460", name="weather", valid_time=TimePeriod(start="2025-01-01T00:00:00Z", end="2099-12-31T00:00:00Z"), - record_schema=schema, ) ds = Datastream(parent_node=node, datastream_resource=ds_resource) - out = ds.schema_to_omjson_dict() - assert out["obsFormat"] == "application/om+json" - assert out["resultSchema"]["name"] == "weather" + schema = ds.fetch_logical_schema() + + assert isinstance(schema, LogicalDatastreamRecordSchema) + assert schema.title == "New Simulated Weather Sensor - weather" + # URL: /sensorhub/api/datastreams/{id}/schema, query: obsFormat=logical + assert captured["url"].endswith("/datastreams/038s1ic7k460/schema") + assert captured["params"] == {"obsFormat": "logical"} -def test_datastream_schema_methods_reject_wrong_variant(node): +def test_datastream_fetch_swejson_schema_uses_correct_obsformat(node, monkeypatch): + """Symmetric: `fetch_swejson_schema()` requests the SWE+JSON format.""" raw = json.loads((FIXTURES_DIR / "fake_weather_schema_swejson.json").read_text()) - schema = SWEDatastreamRecordSchema.from_swejson_dict(raw) + + captured = {} + + class _MockResponse: + def raise_for_status(self): + pass + def json(self): + return raw + + def _mock_get(url, params=None, auth=None, **kwargs): + captured["params"] = params + return _MockResponse() + + monkeypatch.setattr("oshconnect.streamableresource.requests.get", _mock_get) + ds = Datastream(parent_node=node, datastream_resource=DatastreamResource( - ds_id="ds-1", name="w", + ds_id="ds-x", name="w", valid_time=TimePeriod(start="2025-01-01T00:00:00Z", end="2099-12-31T00:00:00Z"), - record_schema=schema, )) - with pytest.raises(TypeError, match="OM\\+JSON"): - ds.schema_to_omjson_dict() + schema = ds.fetch_swejson_schema() + assert isinstance(schema, SWEDatastreamRecordSchema) + assert captured["params"] == {"obsFormat": "application/swe+json"} def test_observation_to_omjson_round_trips(): @@ -212,15 +360,19 @@ def test_observation_to_swejson_round_trips(): assert rebuilt.result == payload -def test_datastream_observation_methods_attach_datastream_id(node): - ds_resource = DatastreamResource( - ds_id="ds-99", name="w", - valid_time=TimePeriod(start="2025-01-01T00:00:00Z", - end="2099-12-31T00:00:00Z"), +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"), ) - ds = Datastream(parent_node=node, datastream_resource=ds_resource) - payload = ds.observation_to_omjson_dict({"temperature": 22.5}) + 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 # =========================================================================== @@ -255,39 +407,16 @@ def test_controlstream_resource_round_trips(): assert rebuilt.cs_id == "cs-001" -def test_controlstream_schema_to_json_dict(node): +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.schema_to_json_dict() + out = cs._underlying_resource.command_schema.to_json_dict() assert out["commandFormat"] == "application/json" assert out["parametersSchema"]["name"] == "params" -def test_controlstream_schema_methods_reject_wrong_variant(node): - cs_resource = _controlstream_resource_with_json_schema() - cs = ControlStream(node=node, controlstream_resource=cs_resource) - with pytest.raises(TypeError, match="SWE\\+JSON"): - cs.schema_to_swejson_dict() - - -def test_controlstream_command_to_json_dict(node): - cs_resource = _controlstream_resource_with_json_schema() - cs = ControlStream(node=node, controlstream_resource=cs_resource) - out = cs.command_to_json_dict({"speed": 1.5}, sender="tester") - assert out["control@id"] == "cs-001" - assert out["sender"] == "tester" - assert out["params"] == {"speed": 1.5} - - -def test_controlstream_command_to_swejson_round_trips(node): - cs_resource = _controlstream_resource_with_json_schema() - cs = ControlStream(node=node, controlstream_resource=cs_resource) - payload = cs.command_to_swejson_dict({"speed": 1.5}) - assert payload == {"speed": 1.5} - rebuilt = ControlStream.command_from_swejson_dict(payload) - assert rebuilt == payload - - def test_command_json_round_trips(): src = CommandJSON(control_id="cs-1", sender="me", params={"x": 1}) dumped = src.to_csapi_dict() @@ -320,7 +449,7 @@ def test_resource_to_csapi_matches_raw_model_dump(build, method): 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_csapi_dict"): + with pytest.warns(DeprecationWarning, match="from_resource"): sys = System.from_system_resource(res, node) assert sys.urn == "urn:osh:sensor:fakeweather:001" @@ -331,6 +460,6 @@ def test_datastream_from_resource_emits_deprecation_warning(node): valid_time=TimePeriod(start="2025-01-01T00:00:00Z", end="2099-12-31T00:00:00Z"), ) - with pytest.warns(DeprecationWarning, match="from_csapi_dict"): + with pytest.warns(DeprecationWarning, match="constructor"): ds = Datastream.from_resource(ds_resource, node) assert ds.get_id() == "ds-1" diff --git a/uv.lock b/uv.lock index c8f4a14..a038ac2 100644 --- a/uv.lock +++ b/uv.lock @@ -25,7 +25,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.12.15" +version = "3.13.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -36,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]] @@ -89,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]] @@ -107,20 +141,20 @@ 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/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/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/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/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 = "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]] @@ -138,46 +172,84 @@ wheels = [ [[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]] @@ -296,76 +368,105 @@ wheels = [ [[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" -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" }, +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]] @@ -386,29 +487,29 @@ wheels = [ [[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]] @@ -453,40 +554,65 @@ wheels = [ [[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]] @@ -521,65 +647,101 @@ wheels = [ [[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" }, +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]] @@ -601,40 +763,63 @@ wheels = [ [[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]] @@ -647,6 +832,7 @@ dependencies = [ { name = "pydantic" }, { name = "requests" }, { name = "shapely" }, + { name = "urllib3" }, { name = "websockets" }, ] @@ -656,6 +842,7 @@ dev = [ { name = "furo" }, { name = "interrogate" }, { name = "myst-parser" }, + { name = "pygments" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "sphinx" }, @@ -668,32 +855,34 @@ tinydb = [ [package.metadata] requires-dist = [ - { name = "aiohttp", specifier = ">=3.12.15" }, + { name = "aiohttp", specifier = ">=3.13.5" }, { name = "flake8", marker = "extra == 'dev'", specifier = ">=7.2.0" }, { name = "furo", marker = "extra == 'dev'", specifier = ">=2024.8.6" }, { name = "interrogate", marker = "extra == 'dev'", specifier = ">=1.7.0" }, { name = "myst-parser", marker = "extra == 'dev'", specifier = ">=4.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 = "pygments", marker = "extra == 'dev'", specifier = ">=2.20.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4.2" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0.0" }, - { name = "requests" }, + { 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-copybutton", marker = "extra == 'dev'", specifier = ">=0.5.2" }, { name = "sphinxcontrib-mermaid", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "tinydb", marker = "extra == 'tinydb'", specifier = ">=4.8.0,<5.0.0" }, - { name = "websockets", specifier = ">=12.0,<16.0" }, + { name = "urllib3", specifier = ">=2.6.3" }, + { name = "websockets", specifier = ">=12.0,<17.0" }, ] provides-extras = ["dev", "tinydb"] [[package]] name = "packaging" -version = "24.2" +version = "26.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" } +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/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" }, + { 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]] @@ -707,68 +896,95 @@ wheels = [ [[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]] @@ -782,16 +998,16 @@ wheels = [ [[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.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -799,113 +1015,118 @@ 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/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } 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/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, ] [[package]] name = "pydantic-core" -version = "2.41.5" +version = "2.46.3" 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/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" }, + { url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" }, + { url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" }, + { url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, + { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, + { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, + { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, + { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, + { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, + { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, + { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, + { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, + { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, + { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, + { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, + { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, + { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, + { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, + { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, + { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, + { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, + { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, ] [[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" +version = "2.20.0" 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" } +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/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, + { 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]] @@ -970,7 +1191,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.3" +version = "2.33.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -978,9 +1199,9 @@ 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]] @@ -1045,11 +1266,11 @@ wheels = [ [[package]] name = "snowballstemmer" -version = "2.2.0" +version = "3.0.1" 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/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/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/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]] @@ -1151,16 +1372,16 @@ wheels = [ [[package]] name = "sphinxcontrib-mermaid" -version = "2.0.1" +version = "2.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, { name = "pyyaml" }, { name = "sphinx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2b/ae/999891de292919b66ea34f2c22fc22c9be90ab3536fbc0fca95716277351/sphinxcontrib_mermaid-2.0.1.tar.gz", hash = "sha256:a21a385a059a6cafd192aa3a586b14bf5c42721e229db67b459dc825d7f0a497", size = 19839, upload-time = "2026-03-05T14:10:41.901Z" } +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/03/46/25d64bcd7821c8d6f1080e1c43d5fcdfc442a18f759a230b5ccdc891093e/sphinxcontrib_mermaid-2.0.1-py3-none-any.whl", hash = "sha256:9dca7fbe827bad5e7e2b97c4047682cfd26e3e07398cfdc96c7a8842ae7f06e7", size = 14064, upload-time = "2026-03-05T14:10:40.533Z" }, + { 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]] @@ -1222,94 +1443,158 @@ wheels = [ [[package]] name = "urllib3" -version = "2.4.0" +version = "2.6.3" 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/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] [[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" }, ] From a27c8ca83f35e6f2de4fb3b2b981684230128abd Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Tue, 5 May 2026 15:59:16 -0500 Subject: [PATCH 13/29] Add missing logical schema fixture --- .../fixtures/fake_weather_schema_logical.json | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 tests/fixtures/fake_weather_schema_logical.json 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" + } + } +} From a2236262dc03867a892e3927274f079d2ced3602 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Tue, 5 May 2026 22:36:35 -0500 Subject: [PATCH 14/29] Fix the relationship between datastreamschema and add_insert datastream. update related docs --- docs/source/architecture/class_hierarchy.md | 4 +- docs/source/architecture/construction.md | 28 ++- docs/source/architecture/serialization.md | 2 +- pyproject.toml | 2 +- src/oshconnect/__init__.py | 4 +- src/oshconnect/resource_datamodels.py | 7 +- src/oshconnect/schema_datamodels.py | 15 +- src/oshconnect/streamableresource.py | 197 ++++++++----------- tests/test_csapi_serialization.py | 2 +- tests/test_discovery.py | 205 ++++++++++++++++++++ tests/test_node_to_node_sync.py | 192 ++++++++++++++++++ tests/test_schema_equivalence.py | 4 +- tests/test_swe_components.py | 12 +- uv.lock | 2 +- 14 files changed, 533 insertions(+), 143 deletions(-) create mode 100644 tests/test_discovery.py create mode 100644 tests/test_node_to_node_sync.py diff --git a/docs/source/architecture/class_hierarchy.md b/docs/source/architecture/class_hierarchy.md index 9efa66e..f34a2ec 100644 --- a/docs/source/architecture/class_hierarchy.md +++ b/docs/source/architecture/class_hierarchy.md @@ -156,7 +156,7 @@ classDiagram +encoding: Encoding +record_schema: AnyComponent } - class JSONDatastreamRecordSchema { + class OMJSONDatastreamRecordSchema { +obs_format = "application/om+json" +result_schema: AnyComponent +parameters_schema: AnyComponent @@ -179,7 +179,7 @@ classDiagram } DatastreamRecordSchema <|-- SWEDatastreamRecordSchema - DatastreamRecordSchema <|-- JSONDatastreamRecordSchema + DatastreamRecordSchema <|-- OMJSONDatastreamRecordSchema CommandSchema <|-- SWEJSONCommandSchema CommandSchema <|-- JSONCommandSchema ``` diff --git a/docs/source/architecture/construction.md b/docs/source/architecture/construction.md index bfd468b..a31c628 100644 --- a/docs/source/architecture/construction.md +++ b/docs/source/architecture/construction.md @@ -61,7 +61,7 @@ For raw CS API JSON, parse it through the resource model first: * - SWE+JSON schema document - `SWEDatastreamRecordSchema.from_swejson_dict(data)` * - OM+JSON schema document - - `JSONDatastreamRecordSchema.from_omjson_dict(data)` + - `OMJSONDatastreamRecordSchema.from_omjson_dict(data)` * - OSH logical schema (`obsFormat=logical`) - `LogicalDatastreamRecordSchema.from_logical_dict(data)` ``` @@ -160,16 +160,29 @@ cs._underlying_resource.command_schema.to_json_dict() ### "I want the schema for an existing datastream from the server" -`Datastream` has three dedicated fetch methods, one per `obsFormat` -the server supports. Each returns a typed schema model so there's no -runtime auto-dispatch: +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, `Datastream` has three dedicated fetch +methods — one per `obsFormat` the server supports. Each returns a +typed schema model: ```python ds = Datastream(parent_node=node, datastream_resource=DatastreamResource.from_csapi_dict(server_response)) # Wire-format schemas (CS API spec) sw = ds.fetch_swejson_schema() # -> SWEDatastreamRecordSchema (application/swe+json) -om = ds.fetch_omjson_schema() # -> JSONDatastreamRecordSchema (application/om+json) +om = ds.fetch_omjson_schema() # -> OMJSONDatastreamRecordSchema (application/om+json) # OSH-specific JSON Schema flavor lg = ds.fetch_logical_schema() # -> LogicalDatastreamRecordSchema (obsFormat=logical) @@ -181,8 +194,9 @@ Each method: parent `Node`'s `APIHelper` for base URL + auth. 2. Parses the response into the corresponding pydantic model. 3. Returns the parsed model — does *not* mutate the datastream's - `_underlying_resource.record_schema`. If you want to cache it, do - it explicitly. + `_underlying_resource.record_schema`. (Discovery is the one place + that opts into caching 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 diff --git a/docs/source/architecture/serialization.md b/docs/source/architecture/serialization.md index 220ca4a..ef4c39c 100644 --- a/docs/source/architecture/serialization.md +++ b/docs/source/architecture/serialization.md @@ -24,7 +24,7 @@ wrapper, route through the resource model. - 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: `JSONDatastreamRecordSchema.to_omjson_dict` / `from_omjson_dict`
OSH logical: `LogicalDatastreamRecordSchema.to_logical_dict` / `from_logical_dict` + - 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` diff --git a/pyproject.toml b/pyproject.toml index 48df1e8..aa81e2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "oshconnect" -version = "0.5.1a0" +version = "0.5.1a1" 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 = [ diff --git a/src/oshconnect/__init__.py b/src/oshconnect/__init__.py index c1a2287..d6906eb 100644 --- a/src/oshconnect/__init__.py +++ b/src/oshconnect/__init__.py @@ -33,7 +33,7 @@ QuantityRangeSchema, TimeRangeSchema, ) -from .schema_datamodels import SWEDatastreamRecordSchema, JSONDatastreamRecordSchema, JSONCommandSchema +from .schema_datamodels import SWEDatastreamRecordSchema, OMJSONDatastreamRecordSchema, JSONCommandSchema # Event system from .events import EventHandler, IEventListener, CallbackListener, DefaultEventTypes, AtomicEventTypes, Event, EventBuilder @@ -76,7 +76,7 @@ "QuantityRangeSchema", "TimeRangeSchema", "SWEDatastreamRecordSchema", - "JSONDatastreamRecordSchema", + "OMJSONDatastreamRecordSchema", "JSONCommandSchema", # Event system "EventHandler", diff --git a/src/oshconnect/resource_datamodels.py b/src/oshconnect/resource_datamodels.py index a18bd8d..6a06cb2 100644 --- a/src/oshconnect/resource_datamodels.py +++ b/src/oshconnect/resource_datamodels.py @@ -209,10 +209,13 @@ 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") @@ -236,7 +239,7 @@ 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` / - `JSONDatastreamRecordSchema`) it holds. + `OMJSONDatastreamRecordSchema`) it holds. """ return self.model_dump(by_alias=True, exclude_none=True, mode='json') diff --git a/src/oshconnect/schema_datamodels.py b/src/oshconnect/schema_datamodels.py index b3a8e25..8ed02d7 100644 --- a/src/oshconnect/schema_datamodels.py +++ b/src/oshconnect/schema_datamodels.py @@ -155,9 +155,10 @@ def from_swejson_dict(cls, data: dict) -> "SWEDatastreamRecordSchema": 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 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) @@ -182,9 +183,9 @@ def _check_obs_format(cls, 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: @@ -192,7 +193,7 @@ def to_omjson_dict(self) -> dict: return _dump_csapi(self) @classmethod - def from_omjson_dict(cls, data: dict) -> "JSONDatastreamRecordSchema": + 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).""" @@ -231,7 +232,7 @@ class LogicalDatastreamRecordSchema(BaseModel): """Logical schema document — OSH's `obsFormat=logical` representation. Returned by ``GET /datastreams/{id}/schema?obsFormat=logical``. Distinct - from `SWEDatastreamRecordSchema` and `JSONDatastreamRecordSchema`: + from `SWEDatastreamRecordSchema` and `OMJSONDatastreamRecordSchema`: - No ``obsFormat`` envelope field - No ``recordSchema`` wrapper — the schema is the document diff --git a/src/oshconnect/streamableresource.py b/src/oshconnect/streamableresource.py index a5b60c8..5cdf30c 100644 --- a/src/oshconnect/streamableresource.py +++ b/src/oshconnect/streamableresource.py @@ -49,31 +49,28 @@ import traceback import uuid import warnings - -import requests from abc import ABC +from collections import deque 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 +import requests from pydantic.alias_generators import to_camel +from .csapi4py.constants import APIResourceTypes, ObservationFormat from .csapi4py.constants import ContentTypes +from .csapi4py.default_api_helpers import APIHelper +from .csapi4py.mqtt import MQTTCommClient 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 .schema_datamodels import JSONCommandSchema from .swe_components import DataRecordSchema from .timemanagement import TimeInstant, TimePeriod, TimeUtils @@ -220,11 +217,9 @@ class Node: _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): + 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 @@ -235,14 +230,10 @@ def __init__(self, protocol: str, address: str, port: int, 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) + 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 = [] @@ -254,9 +245,8 @@ def __init__(self, protocol: str, address: str, port: int, 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 = 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() @@ -280,8 +270,7 @@ 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')) + 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.""" @@ -304,8 +293,7 @@ def discover_systems(self) -> list[System] | None: :return: List of newly-created `System` objects, or ``None`` if the HTTP request failed. """ - result = self._api_helper.retrieve_resource(APIResourceTypes.SYSTEM, - req_headers={}) + result = self._api_helper.retrieve_resource(APIResourceTypes.SYSTEM, req_headers={}) if result.ok: new_systems = [] system_objs = result.json()['items'] @@ -448,11 +436,8 @@ def from_storage_dict(cls, data: dict, session_manager: 'SessionManager' = None) in ``_systems`` was originally registered. """ node = cls( - protocol=data["protocol"], - address=data["address"], - port=data["port"], - username=data.get("username"), - password=data.get("password"), + 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"), @@ -567,8 +552,7 @@ def initialize(self): 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) + resource_id=res_id, subresource_id=None) self._msg_reader_queue = asyncio.Queue() self._msg_writer_queue = asyncio.Queue() self.init_mqtt() @@ -665,10 +649,8 @@ def get_mqtt_topic(self, subresource: APIResourceTypes | None = None, data_topic 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) + 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: @@ -963,6 +945,15 @@ 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 the SWE+JSON + record schema (``GET /datastreams/{id}/schema?obsFormat=application/swe+json``) + and cache it on ``_underlying_resource.record_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. """ res = self._parent_node.get_api_helper().get_resource(APIResourceTypes.SYSTEM, self._resource_id, APIResourceTypes.DATASTREAM) @@ -972,6 +963,14 @@ def discover_datastreams(self) -> list[Datastream]: for ds in datastream_json: datastream_objs = DatastreamResource.model_validate(ds, by_alias=True) new_ds = Datastream(self._parent_node, datastream_objs) + try: + new_ds._underlying_resource.record_schema = new_ds.fetch_swejson_schema() + except Exception as e: + warnings.warn( + f"Failed to fetch SWE+JSON schema for datastream " + f"{datastream_objs.ds_id}: {e}", + stacklevel=2, + ) datastreams.append(new_ds) if not [ds.get_underlying_resource() != datastream_objs for ds in self.datastreams]: @@ -1012,13 +1011,10 @@ def _construct_from_resource(cls, system_resource: SystemResource, parent_node: # label/uid directly on the resource. if other_props.get('properties'): props = other_props['properties'] - new_system = cls(name=props.get('name'), - label=props.get('name'), - urn=props.get('uid'), + new_system = cls(name=props.get('name'), label=props.get('name'), urn=props.get('uid'), resource_id=system_resource.system_id, parent_node=parent_node) else: - new_system = cls(name=system_resource.label, - label=system_resource.label, urn=system_resource.uid, + new_system = cls(name=system_resource.label, 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) @@ -1061,11 +1057,8 @@ def from_system_resource(system_resource: SystemResource, parent_node: Node) -> ``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, - ) + 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: @@ -1086,47 +1079,47 @@ def get_system_resource(self) -> SystemResource: """Return the underlying `SystemResource` model.""" return self._underlying_resource - def add_insert_datastream(self, datarecord_schema: DataRecordSchema): + 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 datarecord_schema: DataRecordSchema to be used to define the + :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: """ - print(f'Adding datastream: {datarecord_schema.model_dump_json(exclude_none=True, by_alias=True)}') + print(f'Adding datastream: {datastream_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")))) + # datastream_record_schema = SWEDatastreamRecordSchema(record_schema=datastream_schema, + # obs_format='application/swe+json', encoding=JSONEncoding()) + # datastream_resource = DatastreamResource(ds_id="default", name=datastream_schema.name, + # output_name=datastream_schema.name, + # record_schema=datastream_record_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)}') + # 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) + 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] print(f'Resource Location: {datastream_id}') - datastream_resource.ds_id = datastream_id + datastream_schema.ds_id = datastream_id else: - raise Exception(f'Failed to create datastream: {datastream_resource.name}') + 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_resource) + 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 @@ -1160,15 +1153,12 @@ def add_and_insert_control_stream(self, control_stream_record_schema: DataRecord 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, + 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) + req_headers={'Content-Type': 'application/json'}, parent_res_id=self._resource_id) if res.ok: control_channel_id = res.headers['Location'].split('/')[-1] @@ -1188,10 +1178,9 @@ def insert_self(self): the ``Location`` response header. """ 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' - }) + 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'] @@ -1267,13 +1256,8 @@ def from_storage_dict(cls, data: dict, node: 'Node') -> 'System': :param node: Parent `Node` the rebuilt system attaches to. """ obj = cls( - name=data["name"], - label=data["label"], - urn=data["urn"], - parent_node=node, - description=data.get("description"), - resource_id=data.get("resource_id") - ) + 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.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", [])] @@ -1332,8 +1316,7 @@ def _fetch_schema_dict(self, obs_format: str) -> dict: """ api = self._parent_node.get_api_helper() url = f"{api.get_api_root_url()}/datastreams/{self._resource_id}/schema" - resp = requests.get(url, params={"obsFormat": obs_format}, - auth=api.get_helper_auth()) + resp = requests.get(url, params={"obsFormat": obs_format}, auth=api.get_helper_auth()) resp.raise_for_status() return resp.json() @@ -1350,13 +1333,13 @@ def fetch_swejson_schema(self): def fetch_omjson_schema(self): """Fetch this datastream's schema in `application/om+json` form - from the server, parsed into a `JSONDatastreamRecordSchema`. + from the server, parsed into an `OMJSONDatastreamRecordSchema`. Hits ``GET /datastreams/{id}/schema?obsFormat=application/om+json``. """ - from .schema_datamodels import JSONDatastreamRecordSchema + from .schema_datamodels import OMJSONDatastreamRecordSchema data = self._fetch_schema_dict(ObservationFormat.JSON.value) - return JSONDatastreamRecordSchema.from_omjson_dict(data) + return OMJSONDatastreamRecordSchema.from_omjson_dict(data) def fetch_logical_schema(self): """Fetch this datastream's schema in OSH's `obsFormat=logical` form @@ -1425,8 +1408,7 @@ def start(self): 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()) + 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 @@ -1435,11 +1417,8 @@ def init_mqtt(self): 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()) + 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): @@ -1488,7 +1467,8 @@ def from_storage_dict(cls, data: dict, node: 'Node') -> 'Datastream': `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 + 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) @@ -1561,14 +1541,9 @@ def get_mqtt_status_topic(self) -> str: 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()) + 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): @@ -1589,8 +1564,7 @@ def start(self): 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()) + 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.""" @@ -1684,7 +1658,8 @@ def from_storage_dict(cls, data: dict, node: 'Node') -> 'ControlStream': `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 + 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") diff --git a/tests/test_csapi_serialization.py b/tests/test_csapi_serialization.py index b62d7e1..e05ef7a 100644 --- a/tests/test_csapi_serialization.py +++ b/tests/test_csapi_serialization.py @@ -30,7 +30,7 @@ from oshconnect.schema_datamodels import ( CommandJSON, JSONCommandSchema, - JSONDatastreamRecordSchema, + OMJSONDatastreamRecordSchema, LogicalDatastreamRecordSchema, ObservationOMJSONInline, SWEDatastreamRecordSchema, diff --git a/tests/test_discovery.py b/tests/test_discovery.py new file mode 100644 index 0000000..8f828c4 --- /dev/null +++ b/tests/test_discovery.py @@ -0,0 +1,205 @@ +"""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.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 both modules discovery touches: + - ``oshconnect.csapi4py.request_wrappers.requests.get`` → listing + - ``oshconnect.streamableresource.requests.get`` → /schema + + ``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, + ) + monkeypatch.setattr( + "oshconnect.streamableresource.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(name="s", 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(name="s", label="S", urn="urn:test:s", + parent_node=node, resource_id="sys-1") + + with pytest.warns(UserWarning, match="Failed to fetch 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, + ) \ 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..9ecf23a --- /dev/null +++ b/tests/test_node_to_node_sync.py @@ -0,0 +1,192 @@ +"""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.resource_datamodels import DatastreamResource +from oshconnect.schema_datamodels import SWEDatastreamRecordSchema +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( + name="SyncTarget", + 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): + """Fetch the first datastream's SWE+JSON schema from the source node, + use its ``recordSchema`` (the inner SWE Common DataRecord) to create a + new datastream on the destination, then verify by fetching the new + schema back and comparing structure.""" + 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 should already have + # populated 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) + + # The explicit fetch path is still supported and exercised here too. + src_schema = src_ds.fetch_swejson_schema() + src_record = src_schema.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` now 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: fetch the new schema from dest and confirm + # the field structure matches the source. + dest_schema = new_ds.fetch_swejson_schema() + dest_record = dest_schema.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}") 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_swe_components.py b/tests/test_swe_components.py index d1b159f..5ed5adb 100644 --- a/tests/test_swe_components.py +++ b/tests/test_swe_components.py @@ -25,7 +25,7 @@ from oshconnect.schema_datamodels import ( JSONCommandSchema, - JSONDatastreamRecordSchema, + OMJSONDatastreamRecordSchema, SWEDatastreamRecordSchema, SWEJSONCommandSchema, ) @@ -124,7 +124,7 @@ def test_swejson_fixture_preserves_names_on_round_trip(): def test_omjson_fixture_preserves_names_on_round_trip(): raw = json.loads((FIXTURES_DIR / "fake_weather_schema_omjson.json").read_text()) - parsed = JSONDatastreamRecordSchema.model_validate(raw) + parsed = OMJSONDatastreamRecordSchema.model_validate(raw) re_dumped = parsed.model_dump(mode="json", by_alias=True, exclude_none=True) assert re_dumped["resultSchema"]["name"] == "weather" @@ -221,12 +221,12 @@ def test_swe_datastream_root_requires_name(): 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"}) + OMJSONDatastreamRecordSchema.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({ + with pytest.raises(ValidationError, match="OMJSONDatastreamRecordSchema.resultSchema"): + OMJSONDatastreamRecordSchema.model_validate({ "obsFormat": "application/json", "resultSchema": { "type": "DataRecord", @@ -505,7 +505,7 @@ def test_swe_datastream_obsformat_recordschema_alias_parity(): @pytest.mark.parametrize("fixture_name,model_cls", [ ("fake_weather_schema_swejson.json", SWEDatastreamRecordSchema), - ("fake_weather_schema_omjson.json", JSONDatastreamRecordSchema), + ("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()) diff --git a/uv.lock b/uv.lock index a038ac2..c1a5039 100644 --- a/uv.lock +++ b/uv.lock @@ -824,7 +824,7 @@ wheels = [ [[package]] name = "oshconnect" -version = "0.5.1a0" +version = "0.5.1a1" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, From 4e25daf47f3ca01cdd7af7cc64958db8303af9d0 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Wed, 6 May 2026 13:06:22 -0500 Subject: [PATCH 15/29] improve some internal usages of basic auth --- docs/source/architecture/insertion.md | 10 +- docs/source/tutorial.rst | 62 +++ src/oshconnect/api_helpers.py | 460 ++++++++++++------ src/oshconnect/csapi4py/con_sys_api.py | 13 +- .../csapi4py/default_api_helpers.py | 6 +- src/oshconnect/streamableresource.py | 68 +-- tests/test_api_helpers_auth.py | 129 +++++ tests/test_csapi_serialization.py | 64 ++- tests/test_discovery.py | 10 +- 9 files changed, 580 insertions(+), 242 deletions(-) create mode 100644 tests/test_api_helpers_auth.py diff --git a/docs/source/architecture/insertion.md b/docs/source/architecture/insertion.md index 86c0453..794b342 100644 --- a/docs/source/architecture/insertion.md +++ b/docs/source/architecture/insertion.md @@ -99,8 +99,14 @@ 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. Issues `requests.post(url, data=body, headers=req_headers, auth=self.auth)`. -3. Returns the raw `requests.Response` — the caller is responsible for +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 diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index 7733825..ec7cd1b 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 --------- 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..8c41fb8 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 @@ -92,7 +92,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..b75ada2 100644 --- a/src/oshconnect/csapi4py/default_api_helpers.py +++ b/src/oshconnect/csapi4py/default_api_helpers.py @@ -152,7 +152,8 @@ def retrieve_resource(self, res_type: APIResourceTypes, res_id: str = None, pare 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 +161,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: @@ -171,6 +173,8 @@ def get_resource(self, resource_type: APIResourceTypes, resource_id: str = None, 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) + if params is not None: + api_request.params = params return api_request.make_request() def update_resource(self, res_type: APIResourceTypes, res_id: str, json_data: any, parent_res_id: str = None, diff --git a/src/oshconnect/streamableresource.py b/src/oshconnect/streamableresource.py index 5cdf30c..9307c71 100644 --- a/src/oshconnect/streamableresource.py +++ b/src/oshconnect/streamableresource.py @@ -58,7 +58,6 @@ from typing import TypeVar, Generic, Union from uuid import UUID, uuid4 -import requests from pydantic.alias_generators import to_camel from .csapi4py.constants import APIResourceTypes, ObservationFormat @@ -70,7 +69,7 @@ from .resource_datamodels import ControlStreamResource from .resource_datamodels import DatastreamResource, ObservationResource from .resource_datamodels import SystemResource -from .schema_datamodels import JSONCommandSchema +from .schema_datamodels import JSONCommandSchema, SWEDatastreamRecordSchema from .swe_components import DataRecordSchema from .timemanagement import TimeInstant, TimePeriod, TimeUtils @@ -955,8 +954,9 @@ def discover_datastreams(self) -> list[Datastream]: datastream's schema fetch is downgraded to a warning so it doesn't poison the whole call. """ - res = self._parent_node.get_api_helper().get_resource(APIResourceTypes.SYSTEM, self._resource_id, - APIResourceTypes.DATASTREAM) + api = self._parent_node.get_api_helper() + res = api.get_resource(APIResourceTypes.SYSTEM, self._resource_id, + APIResourceTypes.DATASTREAM) datastream_json = res.json()['items'] datastreams = [] @@ -964,7 +964,15 @@ def discover_datastreams(self) -> list[Datastream]: datastream_objs = DatastreamResource.model_validate(ds, by_alias=True) new_ds = Datastream(self._parent_node, datastream_objs) try: - new_ds._underlying_resource.record_schema = new_ds.fetch_swejson_schema() + schema_resp = api.get_resource( + APIResourceTypes.DATASTREAM, datastream_objs.ds_id, + APIResourceTypes.SCHEMA, + params={'obsFormat': 'application/swe+json'}, + ) + schema_resp.raise_for_status() + new_ds._underlying_resource.record_schema = ( + SWEDatastreamRecordSchema.from_swejson_dict(schema_resp.json()) + ) except Exception as e: warnings.warn( f"Failed to fetch SWE+JSON schema for datastream " @@ -1305,56 +1313,6 @@ def from_resource(ds_resource: DatastreamResource, parent_node: Node) -> 'Datast ) return Datastream(parent_node=parent_node, datastream_resource=ds_resource) - # ------------------------------------------------------------------ - # Schema retrieval from CS API server (GET /datastreams/{id}/schema) - # ------------------------------------------------------------------ - - def _fetch_schema_dict(self, obs_format: str) -> dict: - """Internal: GET ``/datastreams/{id}/schema?obsFormat={obs_format}`` - through the parent node's APIHelper auth, return the JSON body. - Raises :class:`requests.HTTPError` on non-2xx responses. - """ - api = self._parent_node.get_api_helper() - url = f"{api.get_api_root_url()}/datastreams/{self._resource_id}/schema" - resp = requests.get(url, params={"obsFormat": obs_format}, auth=api.get_helper_auth()) - resp.raise_for_status() - return resp.json() - - def fetch_swejson_schema(self): - """Fetch this datastream's schema in `application/swe+json` form - from the server, parsed into a `SWEDatastreamRecordSchema`. - - Hits ``GET /datastreams/{id}/schema?obsFormat=application/swe+json``. - Auth + base URL come from the parent `Node`'s `APIHelper`. - """ - from .schema_datamodels import SWEDatastreamRecordSchema - data = self._fetch_schema_dict(ObservationFormat.SWE_JSON.value) - return SWEDatastreamRecordSchema.from_swejson_dict(data) - - def fetch_omjson_schema(self): - """Fetch this datastream's schema in `application/om+json` form - from the server, parsed into an `OMJSONDatastreamRecordSchema`. - - Hits ``GET /datastreams/{id}/schema?obsFormat=application/om+json``. - """ - from .schema_datamodels import OMJSONDatastreamRecordSchema - data = self._fetch_schema_dict(ObservationFormat.JSON.value) - return OMJSONDatastreamRecordSchema.from_omjson_dict(data) - - def fetch_logical_schema(self): - """Fetch this datastream's schema in OSH's `obsFormat=logical` form - from the server, parsed into a `LogicalDatastreamRecordSchema`. - - Hits ``GET /datastreams/{id}/schema?obsFormat=logical``. The - response is 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. OSH-specific — - not in the OGC CS API spec. - """ - from .schema_datamodels import LogicalDatastreamRecordSchema - data = self._fetch_schema_dict("logical") - return LogicalDatastreamRecordSchema.from_logical_dict(data) - def set_resource(self, resource: DatastreamResource): """Replace the underlying `DatastreamResource` model.""" self._underlying_resource = resource 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_csapi_serialization.py b/tests/test_csapi_serialization.py index e05ef7a..36ea25d 100644 --- a/tests/test_csapi_serialization.py +++ b/tests/test_csapi_serialization.py @@ -264,68 +264,84 @@ def test_logical_schema_permissive_extra_fields(): assert dumped["properties"]["x"]["minimum"] == 0 -def test_datastream_fetch_logical_schema_hits_correct_endpoint(node, monkeypatch): - """Mock `requests.get` and verify `fetch_logical_schema()` constructs - the right URL + query param + auth, and routes the response through - `LogicalDatastreamRecordSchema`.""" +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, auth=None, **kwargs): - captured["url"] = url + 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.streamableresource.requests.get", _mock_get) + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.get", _mock_get, + ) - ds_resource = DatastreamResource( - ds_id="038s1ic7k460", name="weather", - valid_time=TimePeriod(start="2025-01-01T00:00:00Z", - end="2099-12-31T00:00:00Z"), + resp = retrieve_datastream_schema( + "http://localhost:8282/sensorhub", "038s1ic7k460", + obs_format="logical", ) - ds = Datastream(parent_node=node, datastream_resource=ds_resource) - schema = ds.fetch_logical_schema() + schema = LogicalDatastreamRecordSchema.from_logical_dict(resp.json()) assert isinstance(schema, LogicalDatastreamRecordSchema) assert schema.title == "New Simulated Weather Sensor - weather" - # URL: /sensorhub/api/datastreams/{id}/schema, query: obsFormat=logical assert captured["url"].endswith("/datastreams/038s1ic7k460/schema") assert captured["params"] == {"obsFormat": "logical"} -def test_datastream_fetch_swejson_schema_uses_correct_obsformat(node, monkeypatch): - """Symmetric: `fetch_swejson_schema()` requests the SWE+JSON format.""" +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, auth=None, **kwargs): + def _mock_get(url, params=None, headers=None, auth=None, **kwargs): captured["params"] = params return _MockResponse() - monkeypatch.setattr("oshconnect.streamableresource.requests.get", _mock_get) + monkeypatch.setattr( + "oshconnect.csapi4py.request_wrappers.requests.get", _mock_get, + ) - ds = Datastream(parent_node=node, datastream_resource=DatastreamResource( - ds_id="ds-x", name="w", - valid_time=TimePeriod(start="2025-01-01T00:00:00Z", - end="2099-12-31T00:00:00Z"), - )) - schema = ds.fetch_swejson_schema() + 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"} diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 8f828c4..994f26f 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -120,9 +120,10 @@ def json(self): def _install_dispatching_get(monkeypatch, listing_payload, schema_handler): - """Patch ``requests.get`` at both modules discovery touches: - - ``oshconnect.csapi4py.request_wrappers.requests.get`` → listing - - ``oshconnect.streamableresource.requests.get`` → /schema + """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. @@ -138,9 +139,6 @@ def mock_get(url, params=None, headers=None, auth=None, **kwargs): monkeypatch.setattr( "oshconnect.csapi4py.request_wrappers.requests.get", mock_get, ) - monkeypatch.setattr( - "oshconnect.streamableresource.requests.get", mock_get, - ) def test_discover_datastreams_populates_record_schema(node, monkeypatch): From f50f01dd35f98a0741100dc37b325539bab84d32 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Wed, 6 May 2026 14:48:13 -0500 Subject: [PATCH 16/29] implement fixes to controlstream discovery and insertion as well as add networked tests to verify that commands can be sent based off source schemas and maintain coherence across the wire. --- docs/source/architecture/construction.md | 50 ++-- docs/source/architecture/serialization.md | 7 +- docs/source/tutorial.rst | 161 ++++++++++++ src/oshconnect/schema_datamodels.py | 16 +- src/oshconnect/streamableresource.py | 140 +++++++++-- src/oshconnect/swe_components.py | 5 +- tests/test_controlstream_insert_schema.py | 139 +++++++++++ tests/test_csapi_serialization.py | 7 + tests/test_node_to_node_sync.py | 282 ++++++++++++++++++++-- 9 files changed, 745 insertions(+), 62 deletions(-) create mode 100644 tests/test_controlstream_insert_schema.py diff --git a/docs/source/architecture/construction.md b/docs/source/architecture/construction.md index a31c628..76cb392 100644 --- a/docs/source/architecture/construction.md +++ b/docs/source/architecture/construction.md @@ -173,30 +173,46 @@ 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, `Datastream` has three dedicated fetch -methods — one per `obsFormat` the server supports. Each returns a -typed schema model: +OM+JSON or logical variant, hit the schema endpoint directly through +the parent `Node`'s `APIHelper` and parse with the matching schema +model: ```python -ds = Datastream(parent_node=node, datastream_resource=DatastreamResource.from_csapi_dict(server_response)) +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 -# Wire-format schemas (CS API spec) -sw = ds.fetch_swejson_schema() # -> SWEDatastreamRecordSchema (application/swe+json) -om = ds.fetch_omjson_schema() # -> OMJSONDatastreamRecordSchema (application/om+json) +# 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 = ds.fetch_logical_schema() # -> LogicalDatastreamRecordSchema (obsFormat=logical) +lg_resp = api.get_resource(APIResourceTypes.DATASTREAM, ds_id, + APIResourceTypes.SCHEMA, + params={'obsFormat': 'logical'}) +lg = LogicalDatastreamRecordSchema.from_logical_dict(lg_resp.json()) ``` -Each method: - -1. Hits ``GET /datastreams/{id}/schema?obsFormat={format}`` using the - parent `Node`'s `APIHelper` for base URL + auth. -2. Parses the response into the corresponding pydantic model. -3. Returns the parsed model — does *not* mutate the datastream's - `_underlying_resource.record_schema`. (Discovery is the one place - that opts into caching the SWE+JSON variant; if you want to cache - an OM+JSON or logical fetch, assign it yourself.) +`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 diff --git a/docs/source/architecture/serialization.md b/docs/source/architecture/serialization.md index ef4c39c..12ba81b 100644 --- a/docs/source/architecture/serialization.md +++ b/docs/source/architecture/serialization.md @@ -124,9 +124,10 @@ A third schema model, `LogicalDatastreamRecordSchema`, covers OSH's 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). See [Construction → "I want the schema for an existing -datastream from the server"](construction.md) for the -`Datastream.fetch_logical_schema()` method that retrieves it. +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 diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index ec7cd1b..69bb860 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -139,6 +139,27 @@ 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. + Streaming Observations (MQTT) ------------------------------ @@ -239,6 +260,146 @@ 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. +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) + +By default the wire form is ``application/swe+json`` (spec-compliant CS API +Part 2 — ``commandFormat: "application/swe+json"`` plus ``recordSchema`` plus +a ``JSONEncoding`` block). To target the JSON envelope instead (which is +what OSH echoes back from ``/controlstreams/{id}/schema``), pass +``command_format='application/json'``: + +.. code-block:: python + + control_stream = new_system.add_and_insert_control_stream( + command_record, + command_format='application/json', + ) + +The JSON form emits ``commandFormat: "application/json"`` with a +``parametersSchema`` block (no ``encoding``). + +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: + +.. 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 +----------------------------- +Control streams emit two MQTT topics: ``:commands`` (input) and ``:status`` +(output, where the system reports execution results). 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/src/oshconnect/schema_datamodels.py b/src/oshconnect/schema_datamodels.py index 8ed02d7..71677b4 100644 --- a/src/oshconnect/schema_datamodels.py +++ b/src/oshconnect/schema_datamodels.py @@ -16,6 +16,15 @@ from .encoding import Encoding 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: @@ -35,9 +44,12 @@ 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.""" diff --git a/src/oshconnect/streamableresource.py b/src/oshconnect/streamableresource.py index 9307c71..fced8a2 100644 --- a/src/oshconnect/streamableresource.py +++ b/src/oshconnect/streamableresource.py @@ -60,7 +60,7 @@ from pydantic.alias_generators import to_camel -from .csapi4py.constants import APIResourceTypes, ObservationFormat +from .csapi4py.constants import APIResourceTypes from .csapi4py.constants import ContentTypes from .csapi4py.default_api_helpers import APIHelper from .csapi4py.mqtt import MQTTCommClient @@ -69,7 +69,8 @@ from .resource_datamodels import ControlStreamResource from .resource_datamodels import DatastreamResource, ObservationResource from .resource_datamodels import SystemResource -from .schema_datamodels import JSONCommandSchema, SWEDatastreamRecordSchema +from .encoding import JSONEncoding +from .schema_datamodels import JSONCommandSchema, SWEDatastreamRecordSchema, SWEJSONCommandSchema from .swe_components import DataRecordSchema from .timemanagement import TimeInstant, TimePeriod, TimeUtils @@ -990,15 +991,42 @@ 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``, which OSH + returns as ``application/json`` with a ``parametersSchema`` + SWE Common component) and cache it on + ``_underlying_resource.command_schema``. 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. """ - res = self._parent_node.get_api_helper().get_resource(APIResourceTypes.SYSTEM, self._resource_id, - APIResourceTypes.CONTROL_CHANNEL) + 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, + ) + schema_resp.raise_for_status() + new_cs._underlying_resource.command_schema = ( + JSONCommandSchema.from_json_dict(schema_resp.json()) + ) + except Exception as e: + warnings.warn( + f"Failed to fetch command schema for control stream " + f"{controlstream_objs.cs_id}: {e}", + stacklevel=2, + ) controlstreams.append(new_cs) if not [cs.get_underlying_resource() != controlstream_objs for cs in self.control_channels]: @@ -1132,19 +1160,70 @@ def add_insert_datastream(self, datastream_schema: DatastreamResource): 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` + — a `JSONCommandSchema` for ``application/json`` or a + `SWEJSONCommandSchema` for ``application/swe+json``) and this + method posts it to ``/systems/{id}/controlstreams``, captures + the new resource ID from the ``Location`` header, and returns a + wrapped `ControlStream`. + + :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) -> 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 schema label is - lowercased and whitespace-stripped. + valid_time: TimePeriod = None, + command_format: str = "application/swe+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/swe+json`` → `SWEJSONCommandSchema` carrying + `recordSchema` (the SWE Common component) and `encoding` + (`JSONEncoding`). This is the spec-compliant default. + - ``application/json`` → `JSONCommandSchema` carrying + `parametersSchema` (the SWE Common component); no `encoding`. + + :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/swe+json"`` (default) or + ``"application/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( @@ -1158,8 +1237,23 @@ def add_and_insert_control_stream(self, control_stream_record_schema: DataRecord 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) + 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) @@ -1170,10 +1264,12 @@ def add_and_insert_control_stream(self, control_stream_record_schema: DataRecord 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}') + 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) @@ -1489,6 +1585,10 @@ 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.""" super().init_mqtt() diff --git a/src/oshconnect/swe_components.py b/src/oshconnect/swe_components.py index b4ea584..1877f48 100644 --- a/src/oshconnect/swe_components.py +++ b/src/oshconnect/swe_components.py @@ -128,7 +128,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") diff --git a/tests/test_controlstream_insert_schema.py b/tests/test_controlstream_insert_schema.py new file mode 100644 index 0000000..8ef8dab --- /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( + name="TestSys", 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_swejson_default_emits_recordschema_and_encoding(system, monkeypatch): + """Default `command_format='application/swe+json'` must produce the + spec-compliant 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()) + + 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_json_emits_parametersschema_no_encoding(system, monkeypatch): + """`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(), command_format="application/json", + ) + + 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_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 index 36ea25d..84096d3 100644 --- a/tests/test_csapi_serialization.py +++ b/tests/test_csapi_serialization.py @@ -437,6 +437,13 @@ 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} diff --git a/tests/test_node_to_node_sync.py b/tests/test_node_to_node_sync.py index 9ecf23a..04dcfc5 100644 --- a/tests/test_node_to_node_sync.py +++ b/tests/test_node_to_node_sync.py @@ -21,8 +21,9 @@ import requests from oshconnect import Node, System -from oshconnect.resource_datamodels import DatastreamResource -from oshconnect.schema_datamodels import SWEDatastreamRecordSchema +from oshconnect.csapi4py.constants import APIResourceTypes +from oshconnect.resource_datamodels import ControlStreamResource, DatastreamResource +from oshconnect.schema_datamodels import CommandJSON, JSONCommandSchema, SWEDatastreamRecordSchema from oshconnect.timemanagement import TimeInstant, TimePeriod, TimeUtils SRC_PORT = int(os.environ.get("OSHC_SRC_PORT", "8282")) @@ -104,27 +105,25 @@ def _delete_resource(node: Node, path: str) -> None: @pytest.mark.network def test_swejson_schema_round_trips_src_to_dest(src_node, dest_node): - """Fetch the first datastream's SWE+JSON schema from the source node, - use its ``recordSchema`` (the inner SWE Common DataRecord) to create a - new datastream on the destination, then verify by fetching the new - schema back and comparing structure.""" + """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 should already have - # populated the SWE+JSON schema on the underlying resource. Without - # this, every workflow that needs the schema (cross-node sync, - # observation building, etc.) silently breaks. + # 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) - - # The explicit fetch path is still supported and exercised here too. - src_schema = src_ds.fetch_swejson_schema() - src_record = src_schema.record_schema + 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. @@ -133,7 +132,7 @@ def test_swejson_schema_round_trips_src_to_dest(src_node, dest_node): new_id = None try: - # `System.add_insert_datastream` now takes a fully-built + # `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`. @@ -161,10 +160,17 @@ def test_swejson_schema_round_trips_src_to_dest(src_node, dest_node): f"Location header; got {new_id!r}" ) - # Round-trip verify: fetch the new schema from dest and confirm - # the field structure matches the source. - dest_schema = new_ds.fetch_swejson_schema() - dest_record = dest_schema.record_schema + # 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}" @@ -190,3 +196,241 @@ def test_swejson_schema_round_trips_src_to_dest(src_node, dest_node): _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}") From 774b3c42754772d185d5256a75e3121bd445cba3 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Wed, 6 May 2026 14:49:00 -0500 Subject: [PATCH 17/29] bump alpha version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index aa81e2d..add736e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "oshconnect" -version = "0.5.1a1" +version = "0.5.1a2" 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 = [ From b114f9accf35888cc33f1b2be357571c9c4d7a2f Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Wed, 6 May 2026 22:26:03 -0500 Subject: [PATCH 18/29] improve control stream discovery to have it get the command schema in the same process. --- docs/source/tutorial.rst | 25 +++++---- pyproject.toml | 2 +- src/oshconnect/schema_datamodels.py | 4 +- src/oshconnect/streamableresource.py | 62 +++++++++++++++-------- tests/test_controlstream_insert_schema.py | 42 +++++++-------- tests/test_node_to_node_sync.py | 7 ++- uv.lock | 2 +- 7 files changed, 87 insertions(+), 57 deletions(-) diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index 69bb860..ba0a7d1 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -285,26 +285,31 @@ describing the command structure, then attach it to a system via control_stream = new_system.add_and_insert_control_stream(command_record) -By default the wire form is ``application/swe+json`` (spec-compliant CS API -Part 2 — ``commandFormat: "application/swe+json"`` plus ``recordSchema`` plus -a ``JSONEncoding`` block). To target the JSON envelope instead (which is -what OSH echoes back from ``/controlstreams/{id}/schema``), pass -``command_format='application/json'``: +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/json', + command_format='application/swe+json', ) -The JSON form emits ``commandFormat: "application/json"`` with a -``parametersSchema`` block (no ``encoding``). - 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: +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 diff --git a/pyproject.toml b/pyproject.toml index add736e..e14b610 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "oshconnect" -version = "0.5.1a2" +version = "0.5.1a3" 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 = [ diff --git a/src/oshconnect/schema_datamodels.py b/src/oshconnect/schema_datamodels.py index 71677b4..22df176 100644 --- a/src/oshconnect/schema_datamodels.py +++ b/src/oshconnect/schema_datamodels.py @@ -7,7 +7,7 @@ from __future__ import annotations from datetime import datetime -from typing import Union, List +from typing import Union, List, Literal from pydantic import BaseModel, Field, SerializeAsAny, field_validator, model_validator, HttpUrl, ConfigDict @@ -101,7 +101,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') diff --git a/src/oshconnect/streamableresource.py b/src/oshconnect/streamableresource.py index fced8a2..ccdb890 100644 --- a/src/oshconnect/streamableresource.py +++ b/src/oshconnect/streamableresource.py @@ -993,15 +993,18 @@ def discover_controlstreams(self) -> list[ControlStream]: ``self.control_channels`` and also returned. For each discovered control stream we additionally fetch the - command schema (``GET /controlstreams/{id}/schema``, which OSH - returns as ``application/json`` with a ``parametersSchema`` - SWE Common component) and cache it on - ``_underlying_resource.command_schema``. 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. + 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, @@ -1016,6 +1019,7 @@ def discover_controlstreams(self) -> list[ControlStream]: 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 = ( @@ -1165,12 +1169,21 @@ def add_insert_controlstream(self, controlstream_resource: ControlStreamResource the system's parent node via HTTP POST. Mirrors `add_insert_datastream`: caller assembles the full - `ControlStreamResource` (including the embedded `command_schema` - — a `JSONCommandSchema` for ``application/json`` or a - `SWEJSONCommandSchema` for ``application/swe+json``) and this - method posts it to ``/systems/{id}/controlstreams``, captures - the new resource ID from the ``Location`` header, and returns a - wrapped `ControlStream`. + `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``, @@ -1201,18 +1214,24 @@ def add_insert_controlstream(self, controlstream_resource: ControlStreamResource def add_and_insert_control_stream(self, control_stream_record_schema: DataRecordSchema, input_name: str = None, valid_time: TimePeriod = None, - command_format: str = "application/swe+json") -> ControlStream: + 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/swe+json`` → `SWEJSONCommandSchema` carrying - `recordSchema` (the SWE Common component) and `encoding` - (`JSONEncoding`). This is the spec-compliant default. - ``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 @@ -1222,8 +1241,9 @@ def add_and_insert_control_stream(self, control_stream_record_schema: DataRecord is lowercased and whitespace-stripped. :param valid_time: Optional `TimePeriod`; defaults to ``[now, now + 1 year]``. - :param command_format: ``"application/swe+json"`` (default) or - ``"application/json"``. Anything else raises ``ValueError``. + :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( diff --git a/tests/test_controlstream_insert_schema.py b/tests/test_controlstream_insert_schema.py index 8ef8dab..776275a 100644 --- a/tests/test_controlstream_insert_schema.py +++ b/tests/test_controlstream_insert_schema.py @@ -84,10 +84,10 @@ def _captured_body_json(captured: dict) -> dict: return json.loads(body) -def test_swejson_default_emits_recordschema_and_encoding(system, monkeypatch): - """Default `command_format='application/swe+json'` must produce the - spec-compliant wire form: ``commandFormat: application/swe+json`` plus - ``recordSchema`` plus ``encoding`` (JSONEncoding). NOT ``parametersSchema``.""" +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), @@ -97,37 +97,37 @@ def test_swejson_default_emits_recordschema_and_encoding(system, monkeypatch): 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["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" ) - assert schema["encoding"]["type"] == "JSONEncoding" -def test_json_emits_parametersschema_no_encoding(system, monkeypatch): - """`command_format='application/json'` must produce the JSON wire form: - ``commandFormat: application/json`` plus ``parametersSchema``. NOT - ``recordSchema`` and NOT ``encoding``.""" +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/json", + _record_schema(), command_format="application/swe+json", ) 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" + 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): diff --git a/tests/test_node_to_node_sync.py b/tests/test_node_to_node_sync.py index 04dcfc5..7948af7 100644 --- a/tests/test_node_to_node_sync.py +++ b/tests/test_node_to_node_sync.py @@ -22,8 +22,13 @@ 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, JSONCommandSchema, SWEDatastreamRecordSchema +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")) diff --git a/uv.lock b/uv.lock index c1a5039..4f480d4 100644 --- a/uv.lock +++ b/uv.lock @@ -824,7 +824,7 @@ wheels = [ [[package]] name = "oshconnect" -version = "0.5.1a1" +version = "0.5.1a3" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, From 4a0278120921d998cd422fb2b1a8d22b236485c6 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Thu, 7 May 2026 21:30:54 -0500 Subject: [PATCH 19/29] fix a url generation error for default api helper, improve flexibility of the base consysapi object by subclassing based on request type --- pyproject.toml | 2 +- src/oshconnect/csapi4py/con_sys_api.py | 93 ++- .../csapi4py/default_api_helpers.py | 41 +- tests/test_con_sys_api.py | 574 ++++++++++++++++++ tests/test_default_api_helpers.py | 526 ++++++++++++++++ uv.lock | 2 +- 6 files changed, 1204 insertions(+), 34 deletions(-) create mode 100644 tests/test_con_sys_api.py create mode 100644 tests/test_default_api_helpers.py diff --git a/pyproject.toml b/pyproject.toml index e14b610..5d6df85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "oshconnect" -version = "0.5.1a3" +version = "0.5.1a5" 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 = [ diff --git a/src/oshconnect/csapi4py/con_sys_api.py b/src/oshconnect/csapi4py/con_sys_api.py index 8c41fb8..2a98d9f 100644 --- a/src/oshconnect/csapi4py/con_sys_api.py +++ b/src/oshconnect/csapi4py/con_sys_api.py @@ -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): diff --git a/src/oshconnect/csapi4py/default_api_helpers.py b/src/oshconnect/csapi4py/default_api_helpers.py index b75ada2..6e25ded 100644 --- a/src/oshconnect/csapi4py/default_api_helpers.py +++ b/src/oshconnect/csapi4py/default_api_helpers.py @@ -12,7 +12,7 @@ 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 @@ -122,10 +122,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,10 +144,9 @@ 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, @@ -171,11 +169,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) - if params is not None: - api_request.params = params - 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): @@ -192,12 +187,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): @@ -213,12 +207,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, 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_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/uv.lock b/uv.lock index 4f480d4..563d141 100644 --- a/uv.lock +++ b/uv.lock @@ -824,7 +824,7 @@ wheels = [ [[package]] name = "oshconnect" -version = "0.5.1a3" +version = "0.5.1a4" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, From 602dfaf6b179f8e4e55f64699efca5ceb0401ddb Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Thu, 7 May 2026 22:04:36 -0500 Subject: [PATCH 20/29] fix an issue residing in the default setup for system resources where the properties were not honored due to only accepting the default value for the type property --- docs/source/architecture/construction.md | 2 +- pyproject.toml | 2 +- src/oshconnect/oshconnectapi.py | 4 +- src/oshconnect/resource_datamodels.py | 28 +++-- src/oshconnect/streamableresource.py | 76 ++++++++---- tests/test_csapi_serialization.py | 145 +++++++++++++++++++++++ tests/test_datastore.py | 2 +- uv.lock | 2 +- 8 files changed, 225 insertions(+), 36 deletions(-) diff --git a/docs/source/architecture/construction.md b/docs/source/architecture/construction.md index 76cb392..0d0ce25 100644 --- a/docs/source/architecture/construction.md +++ b/docs/source/architecture/construction.md @@ -237,7 +237,7 @@ with open('my_app_config.json') as 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_new_system(sys) + node.add_system(sys) ``` ## What about new datastreams/controlstreams without going through System? diff --git a/pyproject.toml b/pyproject.toml index 5d6df85..d0f39a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "oshconnect" -version = "0.5.1a5" +version = "0.5.1a7" 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 = [ diff --git a/src/oshconnect/oshconnectapi.py b/src/oshconnect/oshconnectapi.py index 8105915..ce6c8d2 100644 --- a/src/oshconnect/oshconnectapi.py +++ b/src/oshconnect/oshconnectapi.py @@ -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 6a06cb2..f632d6a 100644 --- a/src/oshconnect/resource_datamodels.py +++ b/src/oshconnect/resource_datamodels.py @@ -139,11 +139,19 @@ class SystemResource(BaseModel): def to_smljson_dict(self) -> dict: """Render this system as an `application/sml+json` dict (SensorML JSON encoding). - Sets ``feature_type = "PhysicalSystem"`` to match the SML discriminator - before dumping. Output keys are camelCase per the CS API wire format. + 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``. """ - self.feature_type = "PhysicalSystem" - return self.model_dump(by_alias=True, exclude_none=True, mode='json') + 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`.""" @@ -152,12 +160,14 @@ def to_smljson(self) -> str: def to_geojson_dict(self) -> dict: """Render this system as an `application/geo+json` dict. - Sets ``feature_type = "Feature"`` to match the GeoJSON discriminator - before dumping. Useful when posting to endpoints that expect the - GeoJSON Feature shape. + 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``. """ - self.feature_type = "Feature" - return self.model_dump(by_alias=True, exclude_none=True, mode='json') + 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`.""" diff --git a/src/oshconnect/streamableresource.py b/src/oshconnect/streamableresource.py index ccdb890..605af20 100644 --- a/src/oshconnect/streamableresource.py +++ b/src/oshconnect/streamableresource.py @@ -311,31 +311,32 @@ def discover_systems(self) -> list[System] | None: else: return None - def add_new_system(self, system: System): - """Attach a system to this node without inserting it server-side. - - Use `add_system(system, insert_resource=True)` if you also want to - POST it to the server. - """ - system.set_parent_node(self) - self._systems.append(system) - 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): - """ - 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: + 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() - self.add_new_system(system) + system.set_parent_node(self) self._systems.append(system) return system @@ -1103,10 +1104,35 @@ def from_system_resource(system_resource: SystemResource, parent_node: Node) -> def to_system_resource(self) -> SystemResource: """Render this `System` as a `SystemResource` pydantic model - suitable for POSTing to the server. Wrapper-specific: assembles - attached datastreams into the resource's ``outputs`` list. + 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``. """ - resource = SystemResource(uid=self.urn, label=self.name, feature_type='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.name and not resource.label: + resource.label = self.name + else: + resource = SystemResource(uid=self.urn, label=self.name, + feature_type='PhysicalSystem') if self.datastreams: resource.outputs = [ds.get_underlying_resource() for ds in self.datastreams] return resource @@ -1300,16 +1326,26 @@ 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, - self.to_system_resource().model_dump_json(by_alias=True, exclude_none=True), + 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 print(f'Created system: {self._resource_id}') def retrieve_resource(self): diff --git a/tests/test_csapi_serialization.py b/tests/test_csapi_serialization.py index 84096d3..2c7a0e9 100644 --- a/tests/test_csapi_serialization.py +++ b/tests/test_csapi_serialization.py @@ -149,6 +149,151 @@ def test_system_full_chain_geojson_dict_to_resource_to_wrapper(node): assert sys.name == "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(name="Fresh", 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" + + +# --------------------------------------------------------------------------- +# 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 # =========================================================================== diff --git a/tests/test_datastore.py b/tests/test_datastore.py index 5edfb0f..4340976 100644 --- a/tests/test_datastore.py +++ b/tests/test_datastore.py @@ -255,7 +255,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) diff --git a/uv.lock b/uv.lock index 563d141..dea9ca1 100644 --- a/uv.lock +++ b/uv.lock @@ -824,7 +824,7 @@ wheels = [ [[package]] name = "oshconnect" -version = "0.5.1a4" +version = "0.5.1a6" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, From 9d47ae7d3ee68f4a18d720c399e1ec05c36c795d Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Thu, 7 May 2026 22:54:02 -0500 Subject: [PATCH 21/29] relax a few too strict instances of "label" property. Adjust tests to account for this. Add a SchemaFetchWarning to alert users when this fails so it doesn't get missed. --- pyproject.toml | 2 +- src/oshconnect/streamableresource.py | 29 ++++++-- src/oshconnect/swe_components.py | 3 - tests/test_discovery.py | 42 ++++++++++- tests/test_swe_components.py | 107 +++++++++++++++++++++++---- uv.lock | 2 +- 6 files changed, 158 insertions(+), 27 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d0f39a3..a454182 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "oshconnect" -version = "0.5.1a7" +version = "0.5.1a9" 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 = [ diff --git a/src/oshconnect/streamableresource.py b/src/oshconnect/streamableresource.py index 605af20..a0a380e 100644 --- a/src/oshconnect/streamableresource.py +++ b/src/oshconnect/streamableresource.py @@ -75,6 +75,21 @@ from .timemanagement import TimeInstant, TimePeriod, TimeUtils +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. + """ + + @dataclass(kw_only=True) class Endpoints: """Default URL path segments for an OSH server's REST APIs.""" @@ -976,11 +991,12 @@ def discover_datastreams(self) -> list[Datastream]: SWEDatastreamRecordSchema.from_swejson_dict(schema_resp.json()) ) except Exception as e: - warnings.warn( + msg = ( f"Failed to fetch SWE+JSON schema for datastream " - f"{datastream_objs.ds_id}: {e}", - stacklevel=2, + 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]: @@ -1027,11 +1043,12 @@ def discover_controlstreams(self) -> list[ControlStream]: JSONCommandSchema.from_json_dict(schema_resp.json()) ) except Exception as e: - warnings.warn( + msg = ( f"Failed to fetch command schema for control stream " - f"{controlstream_objs.cs_id}: {e}", - stacklevel=2, + 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]: diff --git a/src/oshconnect/swe_components.py b/src/oshconnect/swe_components.py index 1877f48..0815a52 100644 --- a/src/oshconnect/swe_components.py +++ b/src/oshconnect/swe_components.py @@ -78,7 +78,6 @@ def _fields_require_name(self): class VectorSchema(AnyComponentSchema): - label: str = Field(...) type: Literal["Vector"] = "Vector" definition: str = Field(...) reference_frame: str = Field(..., alias='referenceFrame') @@ -142,7 +141,6 @@ def _items_require_name(self): class GeometrySchema(AnyComponentSchema): - label: str = Field(...) type: Literal["Geometry"] = "Geometry" updatable: bool = Field(False) optional: bool = Field(False) @@ -163,7 +161,6 @@ class GeometrySchema(AnyComponentSchema): class AnySimpleComponentSchema(AnyComponentSchema): - label: str = Field(...) description: str = Field(None) type: str = Field(...) updatable: bool = Field(False) diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 994f26f..5331a1a 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -23,6 +23,7 @@ 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" @@ -191,7 +192,8 @@ def schema_handler(ds_id): sys = System(name="s", label="S", urn="urn:test:s", parent_node=node, resource_id="sys-1") - with pytest.warns(UserWarning, match="Failed to fetch SWE\\+JSON schema"): + with pytest.warns(SchemaFetchWarning, + match=r"Failed to fetch SWE\+JSON schema"): discovered = sys.discover_datastreams() assert len(discovered) == 2 @@ -200,4 +202,42 @@ def schema_handler(ds_id): 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(name="s", 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)" ) \ No newline at end of file diff --git a/tests/test_swe_components.py b/tests/test_swe_components.py index 5ed5adb..08693e8 100644 --- a/tests/test_swe_components.py +++ b/tests/test_swe_components.py @@ -312,9 +312,14 @@ def test_swe_datastream_root_invalid_name_pattern_raises(): # Quantity: [type, definition, label, uom] # Boolean: [type, definition, label] # Text: [type, definition, label] -# Vector: [type, definition, referenceFrame, label, coordinates] +# Vector: [type, definition, referenceFrame, coordinates] # DataRecord:[type, fields] -# Geometry: [type, srs, definition, label] +# 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(): @@ -322,9 +327,9 @@ def test_quantity_requires_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_label_is_optional(): + q = QuantitySchema(definition="http://example.org/x", uom={"code": "m"}) + assert q.label is None def test_quantity_requires_definition(): @@ -332,21 +337,22 @@ def test_quantity_requires_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") +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_requires_label_and_definition(): - with pytest.raises(ValidationError, match="label"): - TextSchema(definition="http://example.org/t") +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_label_definition_referenceframe_coordinates(): +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", @@ -354,7 +360,7 @@ def test_vector_requires_label_definition_referenceframe_coordinates(): definition="http://example.org/x", uom={"code": "m"})], ) - for missing in ("label", "definition", "referenceFrame", "coordinates"): + for missing in ("definition", "referenceFrame", "coordinates"): kwargs = {k: v for k, v in base.items() if k != missing} with pytest.raises(ValidationError): VectorSchema(**kwargs) @@ -365,15 +371,23 @@ def test_datarecord_requires_fields(): DataRecordSchema(name="r") -def test_geometry_requires_srs_definition_label(): +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 ("label", "definition", "srs"): + 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 = [ @@ -566,6 +580,69 @@ def test_vector_accepts_quantity_in_coordinates(): }) +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(): diff --git a/uv.lock b/uv.lock index dea9ca1..9acaffa 100644 --- a/uv.lock +++ b/uv.lock @@ -824,7 +824,7 @@ wheels = [ [[package]] name = "oshconnect" -version = "0.5.1a6" +version = "0.5.1a8" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, From 0d1a0c5b3f30f4cc689a1756a27fe14536c9b146 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 8 May 2026 01:33:32 -0500 Subject: [PATCH 22/29] Fix data models with compound fields by moving them to discriminated unions and forcing rebuilds on the models to fix a forward ref to AnyComponent --- pyproject.toml | 2 +- src/oshconnect/__init__.py | 12 +++- src/oshconnect/resource_datamodels.py | 8 +-- src/oshconnect/schema_datamodels.py | 79 +++++++++++++------- src/oshconnect/streamableresource.py | 53 +++++++------- src/oshconnect/swe_components.py | 19 ++++- tests/test_csapi_serialization.py | 100 ++++++++++++++++++++++++++ tests/test_mqtt_topics.py | 52 ++++++++++++++ uv.lock | 2 +- 9 files changed, 268 insertions(+), 59 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a454182..3308f6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "oshconnect" -version = "0.5.1a9" +version = "0.5.1a11" 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 = [ diff --git a/src/oshconnect/__init__.py b/src/oshconnect/__init__.py index d6906eb..54959e3 100644 --- a/src/oshconnect/__init__.py +++ b/src/oshconnect/__init__.py @@ -33,7 +33,14 @@ QuantityRangeSchema, TimeRangeSchema, ) -from .schema_datamodels import SWEDatastreamRecordSchema, OMJSONDatastreamRecordSchema, JSONCommandSchema +from .schema_datamodels import ( + SWEDatastreamRecordSchema, + OMJSONDatastreamRecordSchema, + SWEJSONCommandSchema, + JSONCommandSchema, + AnyDatastreamRecordSchema, + AnyCommandSchema, +) # Event system from .events import EventHandler, IEventListener, CallbackListener, DefaultEventTypes, AtomicEventTypes, Event, EventBuilder @@ -77,7 +84,10 @@ "TimeRangeSchema", "SWEDatastreamRecordSchema", "OMJSONDatastreamRecordSchema", + "SWEJSONCommandSchema", "JSONCommandSchema", + "AnyDatastreamRecordSchema", + "AnyCommandSchema", # Event system "EventHandler", "IEventListener", diff --git a/src/oshconnect/resource_datamodels.py b/src/oshconnect/resource_datamodels.py index f632d6a..a76e72c 100644 --- a/src/oshconnect/resource_datamodels.py +++ b/src/oshconnect/resource_datamodels.py @@ -9,12 +9,12 @@ 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 .timemanagement import TimeInstant, TimePeriod if TYPE_CHECKING: @@ -227,7 +227,7 @@ class DatastreamResource(BaseModel): 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") @@ -371,7 +371,7 @@ 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: diff --git a/src/oshconnect/schema_datamodels.py b/src/oshconnect/schema_datamodels.py index 22df176..b8cb508 100644 --- a/src/oshconnect/schema_datamodels.py +++ b/src/oshconnect/schema_datamodels.py @@ -7,13 +7,12 @@ from __future__ import annotations from datetime import datetime -from typing import Union, List, Literal +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 JSONEncoding from .geometry import Geometry from .swe_components import AnyComponent, check_named from .timemanagement import TimeInstant @@ -76,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") @@ -140,17 +147,17 @@ 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. + obs_format: Literal[ + "application/swe+json", + "application/swe+csv", + "application/swe+text", + "application/swe+binary", + ] = Field(..., alias='obsFormat') + encoding: JSONEncoding = Field(None) record_schema: AnyComponent = Field(..., alias='recordSchema') - @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 - @model_validator(mode="after") def _root_record_schema_requires_name(self): check_named(self.record_schema, "SWEDatastreamRecordSchema.recordSchema") @@ -178,20 +185,15 @@ class OMJSONDatastreamRecordSchema(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: @@ -339,3 +341,30 @@ 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, 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) +OMJSONDatastreamRecordSchema.model_rebuild(force=True) diff --git a/src/oshconnect/streamableresource.py b/src/oshconnect/streamableresource.py index a0a380e..8a962e4 100644 --- a/src/oshconnect/streamableresource.py +++ b/src/oshconnect/streamableresource.py @@ -1173,20 +1173,7 @@ def add_insert_datastream(self, datastream_schema: DatastreamResource): component requires a name. :return: """ - print(f'Adding datastream: {datastream_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_record_schema = SWEDatastreamRecordSchema(record_schema=datastream_schema, - # obs_format='application/swe+json', encoding=JSONEncoding()) - # datastream_resource = DatastreamResource(ds_id="default", name=datastream_schema.name, - # output_name=datastream_schema.name, - # record_schema=datastream_record_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_schema.model_dump_json(by_alias=True, exclude_none=True), req_headers={'Content-Type': ContentTypes.JSON.value}, @@ -1194,7 +1181,6 @@ def add_insert_datastream(self, datastream_schema: DatastreamResource): if res.ok: datastream_id = res.headers['Location'].split('/')[-1] - print(f'Resource Location: {datastream_id}') datastream_schema.ds_id = datastream_id else: raise Exception( @@ -1714,18 +1700,25 @@ def get_status_deque_outbound(self) -> deque: return self._outbound_status_deque def publish_command(self, payload): - """Publish ``payload`` to the command MQTT topic. Convenience wrapper for ``publish(payload, 'command')``.""" + """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, 'status')``.""" + """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 = 'command'): + 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 should determine specifically allowed types - :param topic: Specific implementation determines the topic from the provided string + + :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: @@ -1733,14 +1726,22 @@ def publish(self, payload, topic: str = 'command'): elif topic == APIResourceTypes.STATUS.value: self._publish_mqtt(self._status_topic, payload) else: - raise ValueError(f"Unsupported topic type {topic} for ControlStream publish().") + 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: 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 + + :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 @@ -1750,7 +1751,11 @@ def subscribe(self, topic=None, callback=None, qos=0): 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'.") + 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) diff --git a/src/oshconnect/swe_components.py b/src/oshconnect/swe_components.py index 0815a52..06b8cf1 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 @@ -82,8 +82,7 @@ class VectorSchema(AnyComponentSchema): 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): @@ -273,3 +272,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/tests/test_csapi_serialization.py b/tests/test_csapi_serialization.py index 2c7a0e9..26734e0 100644 --- a/tests/test_csapi_serialization.py +++ b/tests/test_csapi_serialization.py @@ -334,6 +334,106 @@ def test_datastream_schema_accessible_via_underlying_resource(node): 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) # --------------------------------------------------------------------------- diff --git a/tests/test_mqtt_topics.py b/tests/test_mqtt_topics.py index e22d874..3800055 100644 --- a/tests/test_mqtt_topics.py +++ b/tests/test_mqtt_topics.py @@ -159,6 +159,58 @@ def test_publish_routes_status_to_status_topic(self): f"api/controlstreams/{CS_ID}/status:data", "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): diff --git a/uv.lock b/uv.lock index 9acaffa..988ede7 100644 --- a/uv.lock +++ b/uv.lock @@ -824,7 +824,7 @@ wheels = [ [[package]] name = "oshconnect" -version = "0.5.1a8" +version = "0.5.1a10" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, From 13553c52cd13a113bf6df6d5a362b212b72d47b7 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 8 May 2026 01:52:11 -0500 Subject: [PATCH 23/29] remove excessive prints, made a note to add back useful ones in the log. --- pyproject.toml | 2 +- src/oshconnect/streamableresource.py | 11 +---------- uv.lock | 2 +- 3 files changed, 3 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3308f6a..d05bc54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "oshconnect" -version = "0.5.1a11" +version = "0.5.1a13" 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 = [ diff --git a/src/oshconnect/streamableresource.py b/src/oshconnect/streamableresource.py index 8a962e4..35c064c 100644 --- a/src/oshconnect/streamableresource.py +++ b/src/oshconnect/streamableresource.py @@ -312,9 +312,7 @@ def discover_systems(self) -> list[System] | None: 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(" ", "_")), @@ -793,7 +791,6 @@ def insert_data(self, data: dict): 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) @@ -1349,7 +1346,6 @@ def insert_self(self): self._resource_id = sys_id if self._underlying_resource is not None: self._underlying_resource.system_id = sys_id - print(f'Created system: {self._resource_id}') def retrieve_resource(self): """GET ``/systems/{id}`` and refresh the underlying `SystemResource`. @@ -1361,9 +1357,7 @@ def retrieve_resource(self): 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 @@ -1499,8 +1493,7 @@ def insert_observation_dict(self, obs_data: dict): req_headers={'Content-Type': 'application/json'}) if res.ok: obs_id = res.headers['Location'].split('/')[-1] - print(f'Inserted observation: {obs_id}') - return id + return obs_id else: raise Exception(f'Failed to insert observation: {res.text}') @@ -1535,9 +1528,7 @@ def _emit_inbound_event(self, msg): 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() diff --git a/uv.lock b/uv.lock index 988ede7..e20f096 100644 --- a/uv.lock +++ b/uv.lock @@ -824,7 +824,7 @@ wheels = [ [[package]] name = "oshconnect" -version = "0.5.1a10" +version = "0.5.1a12" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, From defd6144170fb1b98a90cc10d16dc4cfae3368fe Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 8 May 2026 02:38:43 -0500 Subject: [PATCH 24/29] default to new sml model on system resources by default. --- pyproject.toml | 2 +- src/oshconnect/__init__.py | 7 + src/oshconnect/resource_datamodels.py | 104 +++------ src/oshconnect/sensorml.py | 127 +++++++++++ src/oshconnect/streamableresource.py | 123 ++++++++--- tests/test_controlstream_insert_schema.py | 2 +- tests/test_csapi_serialization.py | 40 +++- tests/test_datastore.py | 14 +- tests/test_discovery.py | 137 +++++++++++- tests/test_mqtt_topics.py | 2 +- tests/test_node_to_node_sync.py | 1 - tests/test_sensorml.py | 244 ++++++++++++++++++++++ uv.lock | 2 +- 13 files changed, 690 insertions(+), 115 deletions(-) create mode 100644 src/oshconnect/sensorml.py create mode 100644 tests/test_sensorml.py diff --git a/pyproject.toml b/pyproject.toml index d05bc54..9359469 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "oshconnect" -version = "0.5.1a13" +version = "0.5.1a17" 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 = [ diff --git a/src/oshconnect/__init__.py b/src/oshconnect/__init__.py index 54959e3..a39bf67 100644 --- a/src/oshconnect/__init__.py +++ b/src/oshconnect/__init__.py @@ -42,6 +42,9 @@ AnyCommandSchema, ) +# SensorML structured fields (carried by SystemResource) +from .sensorml import Term, Characteristics, Capabilities + # Event system from .events import EventHandler, IEventListener, CallbackListener, DefaultEventTypes, AtomicEventTypes, Event, EventBuilder @@ -88,6 +91,10 @@ "JSONCommandSchema", "AnyDatastreamRecordSchema", "AnyCommandSchema", + # SensorML structured fields + "Term", + "Characteristics", + "Capabilities", # Event system "EventHandler", "IEventListener", diff --git a/src/oshconnect/resource_datamodels.py b/src/oshconnect/resource_datamodels.py index a76e72c..32493d4 100644 --- a/src/oshconnect/resource_datamodels.py +++ b/src/oshconnect/resource_datamodels.py @@ -15,6 +15,7 @@ from .api_utils import Link from .geometry import Geometry from .schema_datamodels import AnyCommandSchema, AnyDatastreamRecordSchema +from .sensorml import Capabilities, Characteristics, Term from .timemanagement import TimeInstant, TimePeriod if TYPE_CHECKING: @@ -36,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): @@ -103,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") @@ -116,25 +75,28 @@ 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). 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 35c064c..c221c04 100644 --- a/src/oshconnect/streamableresource.py +++ b/src/oshconnect/streamableresource.py @@ -58,8 +58,6 @@ from typing import TypeVar, Generic, Union from uuid import UUID, uuid4 -from pydantic.alias_generators import to_camel - from .csapi4py.constants import APIResourceTypes from .csapi4py.constants import ContentTypes from .csapi4py.default_api_helpers import APIHelper @@ -300,7 +298,23 @@ def get_mqtt_client(self) -> MQTTCommClient: return getattr(self, '_mqtt_client', None) def discover_systems(self) -> list[System] | None: - """GET ``/systems`` and create a `System` for each entry. + """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. @@ -308,16 +322,24 @@ def discover_systems(self) -> list[System] | None: :return: List of newly-created `System` objects, or ``None`` if the HTTP request failed. """ - result = self._api_helper.retrieve_resource(APIResourceTypes.SYSTEM, req_headers={}) + 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) - 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) - + # 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 @@ -925,7 +947,6 @@ class System(StreamableResource[SystemResource]): or `add_insert_datastream` / `add_and_insert_control_stream` to create new ones server-side. """ - name: str label: str datastreams: list[Datastream] control_channels: list[ControlStream] @@ -933,16 +954,39 @@ class System(StreamableResource[SystemResource]): urn: str _parent_node: Node - def __init__(self, name: str, label: str, urn: str, parent_node: Node, **kwargs): + def __init__(self, label: str = None, urn: str = None, parent_node: Node = None, **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 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) - self.name = name + + # 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 = [] @@ -954,6 +998,31 @@ def __init__(self, name: str, label: str, urn: str, parent_node: Node, **kwargs) 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 + def discover_datastreams(self) -> list[Datastream]: """GET ``/systems/{id}/datastreams`` and instantiate `Datastream` objects for every entry. New datastreams are appended to @@ -1062,14 +1131,15 @@ def _construct_from_resource(cls, system_resource: SystemResource, parent_node: # 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 name/uid under properties; SML form has - # label/uid directly on the resource. + # 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(name=props.get('name'), label=props.get('name'), urn=props.get('uid'), + 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(name=system_resource.label, label=system_resource.label, urn=system_resource.uid, + 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) @@ -1142,10 +1212,10 @@ def to_system_resource(self) -> SystemResource: # resource on assignment). if self.urn and not resource.uid: resource.uid = self.urn - if self.name and not resource.label: - resource.label = self.name + if self.label and not resource.label: + resource.label = self.label else: - resource = SystemResource(uid=self.urn, label=self.name, + 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] @@ -1370,7 +1440,6 @@ def to_storage_dict(self) -> dict: block is the only piece that matches the CS API system shape. """ data = super().to_storage_dict() - data["name"] = getattr(self, "name", None) data["label"] = getattr(self, "label", None) data["urn"] = getattr(self, "urn", None) data["description"] = getattr(self, "description", None) @@ -1403,17 +1472,23 @@ def to_storage_dict(self) -> dict: def from_storage_dict(cls, data: dict, node: 'Node') -> 'System': """Build a `System` from a dict produced by `to_storage_dict`. - Expects ``name``, ``label``, ``urn``, optional ``description`` / + 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( - name=data["name"], label=data["label"], urn=data["urn"], parent_node=node, + 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", [])] diff --git a/tests/test_controlstream_insert_schema.py b/tests/test_controlstream_insert_schema.py index 776275a..bd58e8d 100644 --- a/tests/test_controlstream_insert_schema.py +++ b/tests/test_controlstream_insert_schema.py @@ -67,7 +67,7 @@ def system(monkeypatch) -> System: `_resource_id` is populated for the controlstream POST.""" node = Node(protocol="http", address="localhost", port=8585) sys = System( - name="TestSys", label="Test System", urn="urn:test:sys:1", + label="Test System", urn="urn:test:sys:1", parent_node=node, resource_id="sys-1", ) return sys diff --git a/tests/test_csapi_serialization.py b/tests/test_csapi_serialization.py index 26734e0..0d18457 100644 --- a/tests/test_csapi_serialization.py +++ b/tests/test_csapi_serialization.py @@ -125,7 +125,7 @@ def test_system_from_resource_handles_geojson_shape(node): ) sys = System.from_resource(res, node) assert sys.urn == "urn:test:geo" - assert sys.name == "GeoSys" + assert sys.label == "GeoSys" def test_system_full_chain_smljson_dict_to_resource_to_wrapper(node): @@ -146,7 +146,7 @@ def test_system_full_chain_geojson_dict_to_resource_to_wrapper(node): res = SystemResource.from_geojson_dict(raw) sys = System.from_resource(res, node) assert sys.urn == "urn:test:geo:2" - assert sys.name == "GeoSys2" + assert sys.label == "GeoSys2" # --------------------------------------------------------------------------- @@ -229,13 +229,47 @@ def test_to_system_resource_thin_shell_for_freshly_constructed(node): produces a sensible thin shell with default ``PhysicalSystem`` type — backward-compat with code that doesn't go through discovery.""" - sys = System(name="Fresh", label="Fresh", urn="urn:test:fresh:1", + 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 # --------------------------------------------------------------------------- diff --git a/tests/test_datastore.py b/tests/test_datastore.py index 4340976..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:") @@ -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_discovery.py b/tests/test_discovery.py index 5331a1a..28e9639 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -156,7 +156,7 @@ def test_discover_datastreams_populates_record_schema(node, monkeypatch): schema_handler=lambda ds_id: _MockResponse(swe_schema), ) - sys = System(name="s", label="S", urn="urn:test:s", + sys = System(label="S", urn="urn:test:s", parent_node=node, resource_id="sys-1") discovered = sys.discover_datastreams() @@ -189,7 +189,7 @@ def schema_handler(ds_id): schema_handler=schema_handler, ) - sys = System(name="s", label="S", urn="urn:test:s", + sys = System(label="S", urn="urn:test:s", parent_node=node, resource_id="sys-1") with pytest.warns(SchemaFetchWarning, @@ -225,7 +225,7 @@ def schema_handler(ds_id): schema_handler=schema_handler, ) - sys = System(name="s", label="S", urn="urn:test:s", + sys = System(label="S", urn="urn:test:s", parent_node=node, resource_id="sys-1") import logging as _logging @@ -240,4 +240,133 @@ def schema_handler(ds_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)" - ) \ No newline at end of file + ) + + +# --------------------------------------------------------------------------- +# 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_mqtt_topics.py b/tests/test_mqtt_topics.py index 3800055..30e6676 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 diff --git a/tests/test_node_to_node_sync.py b/tests/test_node_to_node_sync.py index 7948af7..44a8092 100644 --- a/tests/test_node_to_node_sync.py +++ b/tests/test_node_to_node_sync.py @@ -89,7 +89,6 @@ def _ensure_dest_system(node: Node) -> tuple[System, bool]: if systems: return systems[0], False sys = System( - name="SyncTarget", label="Sync Target System", urn=f"urn:test:cross-node-sync:{uuid.uuid4().hex[:8]}", parent_node=node, 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/uv.lock b/uv.lock index e20f096..a88c99c 100644 --- a/uv.lock +++ b/uv.lock @@ -824,7 +824,7 @@ wheels = [ [[package]] name = "oshconnect" -version = "0.5.1a12" +version = "0.5.1a16" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, From eff31db26bdc6547fcbf9cb41fd6c145649a2fc9 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 8 May 2026 02:52:28 -0500 Subject: [PATCH 25/29] update dependencies --- pyproject.toml | 40 ++++++----- uv.lock | 182 ++++++++++++++++++++++++------------------------- 2 files changed, 113 insertions(+), 109 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9359469..699a012 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "oshconnect" -version = "0.5.1a17" +version = "0.5.1a18" 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 = [ @@ -9,36 +9,40 @@ authors = [ 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,<17.0", - # Floors below resolve open Dependabot alerts (May 2026 sweep). See the - # security tab for the per-advisory list; collectively these fix 25 of 27. + # 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.6.3", # transitive via requests; explicit floor pins the patched version + "urllib3>=2.7.0", # transitive via requests; explicit floor pins the patched version ] [project.optional-dependencies] dev = [ - "flake8>=7.2.0", - # pytest>=8.4.2 picks up the tmpdir handling fix (GHSA / Dependabot alert #27). - # 9.x verified compatible (May 2026): only PytestRemovedIn9Warning -> error - # could bite, and our suite uses none of those deprecated APIs. - "pytest>=8.4.2", - "pytest-cov>=5.0.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>=7.4.7", - "furo>=2024.8.6", - "myst-parser>=4.0.0", - "sphinxcontrib-mermaid>=1.0.0", + # 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/"]}} diff --git a/uv.lock b/uv.lock index a88c99c..a8b61c5 100644 --- a/uv.lock +++ b/uv.lock @@ -542,14 +542,14 @@ wheels = [ [[package]] name = "markdown-it-py" -version = "4.0.0" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +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/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, + { 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]] @@ -626,14 +626,14 @@ wheels = [ [[package]] name = "mdit-py-plugins" -version = "0.5.0" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +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/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, + { 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]] @@ -824,7 +824,7 @@ wheels = [ [[package]] name = "oshconnect" -version = "0.5.1a16" +version = "0.5.1a17" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, @@ -856,23 +856,23 @@ tinydb = [ [package.metadata] requires-dist = [ { name = "aiohttp", specifier = ">=3.13.5" }, - { name = "flake8", marker = "extra == 'dev'", specifier = ">=7.2.0" }, - { name = "furo", marker = "extra == 'dev'", specifier = ">=2024.8.6" }, + { name = "flake8", marker = "extra == 'dev'", specifier = ">=7.3.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 = ">=4.0.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 = "pydantic", specifier = ">=2.13.4,<3.0.0" }, { name = "pygments", marker = "extra == 'dev'", specifier = ">=2.20.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4.2" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0.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", marker = "extra == 'dev'", specifier = ">=9.0.0" }, { name = "sphinx-copybutton", marker = "extra == 'dev'", specifier = ">=0.5.2" }, - { name = "sphinxcontrib-mermaid", marker = "extra == 'dev'", specifier = ">=1.0.0" }, - { name = "tinydb", marker = "extra == 'tinydb'", specifier = ">=4.8.0,<5.0.0" }, - { name = "urllib3", specifier = ">=2.6.3" }, - { name = "websockets", specifier = ">=12.0,<17.0" }, + { 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"] @@ -1007,7 +1007,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.13.3" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -1015,84 +1015,84 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +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/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, + { 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.46.3" +version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" }, - { url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" }, - { url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" }, - { url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" }, - { url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" }, - { url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" }, - { url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" }, - { url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" }, - { url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" }, - { url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" }, - { url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" }, - { url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" }, - { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, - { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, - { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, - { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, - { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, - { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, - { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, - { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, - { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, - { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, - { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, - { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, - { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, - { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, - { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, - { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, - { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, - { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, - { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, - { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, - { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, - { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, - { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, - { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, - { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, - { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, - { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, - { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, - { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, - { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, - { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, - { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, - { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, - { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, - { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, - { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, - { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, - { url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" }, - { url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" }, - { url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" }, - { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, +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]] @@ -1443,11 +1443,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +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/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { 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]] From 931e2ac2c60cab5f12bf1b59db60521cdf168007 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Tue, 19 May 2026 10:29:53 -0500 Subject: [PATCH 26/29] split monolithic streamableresource.py into easier to understand individual files --- pyproject.toml | 2 +- src/oshconnect/node.py | 448 +++++ src/oshconnect/resources/__init__.py | 35 + src/oshconnect/resources/base.py | 513 ++++++ src/oshconnect/resources/controlstream.py | 219 +++ src/oshconnect/resources/datastream.py | 213 +++ src/oshconnect/resources/system.py | 594 +++++++ src/oshconnect/streamableresource.py | 1901 +-------------------- uv.lock | 2 +- 9 files changed, 2065 insertions(+), 1862 deletions(-) create mode 100644 src/oshconnect/node.py create mode 100644 src/oshconnect/resources/__init__.py create mode 100644 src/oshconnect/resources/base.py create mode 100644 src/oshconnect/resources/controlstream.py create mode 100644 src/oshconnect/resources/datastream.py create mode 100644 src/oshconnect/resources/system.py diff --git a/pyproject.toml b/pyproject.toml index 699a012..d99e432 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "oshconnect" -version = "0.5.1a18" +version = "0.5.1a19" 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 = [ 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/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..191854d --- /dev/null +++ b/src/oshconnect/resources/base.py @@ -0,0 +1,513 @@ +# ============================================================================= +# 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): + """ + 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): + """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: 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 + """ + 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): + """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..da0dcf6 --- /dev/null +++ b/src/oshconnect/resources/controlstream.py @@ -0,0 +1,219 @@ +# ============================================================================= +# 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.""" + super().init_mqtt() + self._topic = self.get_mqtt_topic(subresource=APIResourceTypes.COMMAND, data_topic=True) + + def get_mqtt_status_topic(self) -> str: + """Return the MQTT topic for command status updates (``:status``).""" + 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): + """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..8be4a1e --- /dev/null +++ b/src/oshconnect/resources/datastream.py @@ -0,0 +1,213 @@ +# ============================================================================= +# 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 ..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).""" + 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): + self._msg_writer_queue.put_nowait(msg) + + def _queue_pop(self): + return self._msg_reader_queue.get_nowait() + + def insert(self, data: dict): + """Encode ``data`` as JSON and publish it to this datastream's + observation MQTT topic. Bypasses the outbound deque.""" + # self._queue_push(data) + encoded = json.dumps(data).encode('utf-8') + self._publish_mqtt(self._topic, encoded) + + 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..ba89238 --- /dev/null +++ b/src/oshconnect/resources/system.py @@ -0,0 +1,594 @@ +# ============================================================================= +# 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, SWEDatastreamRecordSchema, SWEJSONCommandSchema +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 + + 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 the SWE+JSON + record schema (``GET /datastreams/{id}/schema?obsFormat=application/swe+json``) + and cache it on ``_underlying_resource.record_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) + try: + schema_resp = api.get_resource( + APIResourceTypes.DATASTREAM, datastream_objs.ds_id, + APIResourceTypes.SCHEMA, + params={'obsFormat': 'application/swe+json'}, + ) + schema_resp.raise_for_status() + new_ds._underlying_resource.record_schema = ( + SWEDatastreamRecordSchema.from_swejson_dict(schema_resp.json()) + ) + except Exception as e: + msg = ( + f"Failed to fetch SWE+JSON 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/streamableresource.py b/src/oshconnect/streamableresource.py index c221c04..fa20ead 100644 --- a/src/oshconnect/streamableresource.py +++ b/src/oshconnect/streamableresource.py @@ -1,1868 +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 # ============================================================================= -""" -Streamable resource hierarchy: the user-facing primitives for talking to an -OpenSensorHub server. - -Object model ------------- - -:: +"""Backward-compatible re-export shim. - Node # connection to one OSH server - ├── APIHelper # builds and executes HTTP requests - └── System[] # discovered or user-created sensor systems - ├── Datastream[] # output channels (observations) - └── ControlStream[] # input channels (commands + status) +The classes that used to live in this module have moved into focused +sibling modules: -`Node`, `System`, `Datastream`, and `ControlStream` are the types most user -code touches. `StreamableResource` is the abstract base that powers MQTT -streaming, WebSocket connections, and inbound/outbound message queues for -all three concrete subclasses. +- `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` -Conventions ------------ - -- Construction → `initialize()` (sets up MQTT subscriptions and the WS URL) - → `start()` (opens the streaming loop). `stop()` tears down. -- Inbound MQTT messages land in `_inbound_deque`; outbound payloads queued - via `publish()` / `insert_data()` flow through `_outbound_deque`. -- Resource creation (`add_insert_datastream`, `add_and_insert_control_stream`, - `insert_self`) goes through the parent `Node`'s `APIHelper` and a - `Location` header on the response is parsed to capture the new server-side - ID. -- `StreamableModes`: `PUSH` = we publish, `PULL` = we subscribe, - `BIDIRECTIONAL` = both. Defaults to `PUSH` on construction. +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 __future__ import annotations - -import asyncio -import base64 -import datetime -import json -import logging -import traceback -import uuid -import warnings -from abc import ABC -from collections import deque -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 .csapi4py.constants import APIResourceTypes -from .csapi4py.constants import ContentTypes -from .csapi4py.default_api_helpers import APIHelper -from .csapi4py.mqtt import MQTTCommClient -from .events import EventHandler, DefaultEventTypes -from .events.builder import EventBuilder -from .resource_datamodels import ControlStreamResource -from .resource_datamodels import DatastreamResource, ObservationResource -from .resource_datamodels import SystemResource -from .encoding import JSONEncoding -from .schema_datamodels import JSONCommandSchema, SWEDatastreamRecordSchema, SWEJSONCommandSchema -from .swe_components import DataRecordSchema -from .timemanagement import TimeInstant, TimePeriod, TimeUtils - - -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. - """ - - -@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. - """ - 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. - """ - 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 - - -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): - """ - 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): - """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: 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 - """ - 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): - """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 - - -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 - - 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 the SWE+JSON - record schema (``GET /datastreams/{id}/schema?obsFormat=application/swe+json``) - and cache it on ``_underlying_resource.record_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) - try: - schema_resp = api.get_resource( - APIResourceTypes.DATASTREAM, datastream_objs.ds_id, - APIResourceTypes.SCHEMA, - params={'obsFormat': 'application/swe+json'}, - ) - schema_resp.raise_for_status() - new_ds._underlying_resource.record_schema = ( - SWEDatastreamRecordSchema.from_swejson_dict(schema_resp.json()) - ) - except Exception as e: - msg = ( - f"Failed to fetch SWE+JSON 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 - - -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).""" - 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): - self._msg_writer_queue.put_nowait(msg) - - def _queue_pop(self): - return self._msg_reader_queue.get_nowait() - - def insert(self, data: dict): - """Encode ``data`` as JSON and publish it to this datastream's - observation MQTT topic. Bypasses the outbound deque.""" - # self._queue_push(data) - encoded = json.dumps(data).encode('utf-8') - self._publish_mqtt(self._topic, encoded) - - 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) - - -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.""" - super().init_mqtt() - self._topic = self.get_mqtt_topic(subresource=APIResourceTypes.COMMAND, data_topic=True) - - def get_mqtt_status_topic(self) -> str: - """Return the MQTT topic for command status updates (``:status``).""" - 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): - """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 +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/uv.lock b/uv.lock index a8b61c5..7d69f41 100644 --- a/uv.lock +++ b/uv.lock @@ -824,7 +824,7 @@ wheels = [ [[package]] name = "oshconnect" -version = "0.5.1a17" +version = "0.5.1a19" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, From e8b4dc3a416cc0f094bcb5e464b1d3c7943def5e Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Tue, 19 May 2026 23:49:51 -0500 Subject: [PATCH 27/29] add support for swe+binary as well as experimental support for protobuf and flatbuffers --- .gitignore | 3 + docs/source/tutorial.rst | 209 ++++++++ examples/axis_video_frame.py | 327 +++++++++++++ pyproject.toml | 15 +- src/oshconnect/__init__.py | 31 ++ src/oshconnect/encoding.py | 156 +++++- src/oshconnect/resources/base.py | 24 +- src/oshconnect/resources/datastream.py | 82 +++- src/oshconnect/resources/system.py | 92 +++- src/oshconnect/schema_datamodels.py | 142 +++++- src/oshconnect/swe_binary.py | 478 +++++++++++++++++++ src/oshconnect/swe_components.py | 11 +- src/oshconnect/swe_flatbuffers.py | 75 +++ src/oshconnect/swe_protobuf.py | 634 +++++++++++++++++++++++++ tests/test_discovery.py | 2 +- tests/test_swe_binary.py | 622 ++++++++++++++++++++++++ tests/test_swe_flatbuffers.py | 88 ++++ tests/test_swe_protobuf.py | 390 +++++++++++++++ uv.lock | 134 +++++- 19 files changed, 3471 insertions(+), 44 deletions(-) create mode 100644 examples/axis_video_frame.py create mode 100644 src/oshconnect/swe_binary.py create mode 100644 src/oshconnect/swe_flatbuffers.py create mode 100644 src/oshconnect/swe_protobuf.py create mode 100644 tests/test_swe_binary.py create mode 100644 tests/test_swe_flatbuffers.py create mode 100644 tests/test_swe_protobuf.py diff --git a/.gitignore b/.gitignore index 5779839..1f1ee20 100644 --- a/.gitignore +++ b/.gitignore @@ -164,3 +164,6 @@ cython_debug/ .python-version poetry.lock + +# Demo-script artifacts (examples/axis_video_frame.py writes here) +examples/_out/ diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index ba0a7d1..6f3d28d 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -260,6 +260,215 @@ 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 diff --git a/examples/axis_video_frame.py b/examples/axis_video_frame.py new file mode 100644 index 0000000..d39a4c0 --- /dev/null +++ b/examples/axis_video_frame.py @@ -0,0 +1,327 @@ +#!/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. + +Hits an Axis-camera-backed OSH datastream, pulls H.264 frames as +``application/swe+binary``, decodes each record with `SWEBinaryCodec`, +**re-encodes** them, and pops a side-by-side tkinter window comparing: + +* the H.264 frame decoded from the *raw* bytes the OSH node sent, and +* the H.264 frame decoded after a full encode→decode roundtrip through + ``SWEBinaryCodec`` + ``encode_swe_binary_blob``. + +If the codec is faithful, the two panels are pixel-identical and the +verdict label reads "Byte-for-byte identical". Any divergence shows up +visually and in the printed byte-comparison. + +Defaults +-------- +* Node: ``http://localhost:9191/sensorhub/api`` (the Axis test node) +* Datastream: ``040g`` (the ``video1`` output) +* Frames: ``30`` (enough to land at least one keyframe in practice) + +Override with the env vars ``OSHC_AXIS_PORT``, ``OSHC_AXIS_DS``, and +``OSHC_AXIS_FRAMES`` respectively. + +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 pathlib import Path + +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") +DS_ID = os.environ.get("OSHC_AXIS_DS", "040g") +N_FRAMES = int(os.environ.get("OSHC_AXIS_FRAMES", "30")) +BASE_URL = f"http://localhost:{PORT}/sensorhub/api" +OUT_DIR = Path("examples/_out") + + +# --------------------------------------------------------------------------- +# 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() -> 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(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(codec: SWEBinaryCodec, raw: bytes) -> bytes: + """Decode → re-encode the first record; print + assert byte-identity. + + Returns the H.264 NAL bytes for the first decoded record so the caller + can save them. + """ + print("\n=== 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)") + + # Re-encode with our codec + 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("\n✓ Byte-for-byte identical.") + else: + print("\n✗ 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 decoded["img"] + + +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"\nWrote {count} NAL units ({total} bytes) → {out_path}") + return count + + +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 + + +def show_side_by_side_gui(codec: SWEBinaryCodec, raw: bytes) -> None: + """Show side-by-side: frame as decoded from the OSH node's raw wire bytes + vs. frame as decoded after a full encode→decode round-trip through our codec. + + Walks every record in `raw` to build two parallel NAL streams (one + direct, one through the codec). Decodes the first frame of each and + presents them in a tkinter window with a match/mismatch verdict. + """ + try: + import tkinter as tk + + import av # noqa: F401 (PyAV needed for _decode_first_frame) + 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 + + print("\n=== Building parallel NAL streams (node vs. codec) ===") + 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"] + # Round-trip through our codec, then re-decode to extract the NAL. + 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 + print(f" {n_records} records → {len(node_nals)} bytes per stream") + identical = bytes(node_nals) == bytes(codec_nals) + print(f" NAL streams identical: {identical}") + + print("Decoding first frame of each stream with PyAV…") + frame_node = _decode_first_frame(bytes(node_nals)) + frame_codec = _decode_first_frame(bytes(codec_nals)) + if frame_node is None or frame_codec is None: + print(" could not decode at least one stream; skipping GUI.") + return + + h, w = frame_node.shape[:2] + # Resize so the side-by-side fits a typical laptop screen (~1400 px wide). + target_w = 600 + scale = min(1.0, target_w / w) + new_size = (max(1, int(w * scale)), max(1, int(h * scale))) + + root = tk.Tk() + root.title("OSH camera — SWE+binary codec fidelity") + + container = tk.Frame(root, padx=12, pady=12) + container.pack() + + header_text = ( + f"Datastream {DS_ID} · {n_records} records · " + f"{w}×{h} → display {new_size[0]}×{new_size[1]}" + ) + tk.Label(container, text=header_text, font=("Helvetica", 11)).grid( + row=0, column=0, columnspan=2, pady=(0, 8)) + + tk.Label(container, text="From OSH node\n(direct H.264 decode)", + font=("Helvetica", 12, "bold")).grid(row=1, column=0, padx=6) + tk.Label(container, text="Through OSHConnect codec\n(decode → encode → decode)", + font=("Helvetica", 12, "bold")).grid(row=1, column=1, padx=6) + + # Keep refs alive on the root or they're garbage-collected before render. + root._img_node = ImageTk.PhotoImage(Image.fromarray(frame_node).resize(new_size)) + root._img_codec = ImageTk.PhotoImage(Image.fromarray(frame_codec).resize(new_size)) + tk.Label(container, image=root._img_node, borderwidth=2, relief="solid").grid( + row=2, column=0, padx=6, pady=4) + tk.Label(container, image=root._img_codec, borderwidth=2, relief="solid").grid( + row=2, column=1, padx=6, pady=4) + + verdict = "✓ Byte-for-byte identical" if identical else "✗ Mismatch" + color = "#1b8a3a" if identical else "#b1331e" + tk.Label(container, text=f"NAL stream verdict: {verdict}", + font=("Helvetica", 12, "bold"), fg=color).grid( + row=3, column=0, columnspan=2, pady=(10, 0)) + + tk.Label(container, + text="Close the window to exit.", + font=("Helvetica", 9), fg="#666").grid( + row=4, column=0, columnspan=2, pady=(4, 0)) + + root.mainloop() + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main() -> int: + print(f"Hitting {BASE_URL}/datastreams/{DS_ID}") + + try: + schema = fetch_schema() + except Exception as exc: + print(f"ERROR: could not fetch schema: {exc}") + return 1 + print("✓ Fetched swe+binary schema") + members = [m.ref for m in schema.record_encoding.members] + print(f" members: {members}") + + codec = SWEBinaryCodec(schema) + + try: + raw = fetch_observations(limit=N_FRAMES) + except Exception as exc: + print(f"ERROR: could not fetch observations: {exc}") + return 1 + print(f"✓ Fetched {len(raw)} bytes ({N_FRAMES} requested)") + + # Round-trip the first record + try: + compare_round_trip(codec, raw) + except Exception as exc: + print(f"ERROR: round-trip failed: {exc}") + return 1 + + # Save the full NAL stream + h264_path = OUT_DIR / "axis_frames.h264" + try: + save_nal_stream(codec, raw, h264_path) + except struct.error as exc: + print(f"WARNING: could not walk all records ({exc}) — partial file written") + except Exception as exc: + print(f"WARNING: error while saving NAL stream: {exc}") + + # Pop the side-by-side comparison GUI. Blocks until the user closes + # the window; skipped automatically when PyAV/Pillow/tkinter aren't + # available. + show_side_by_side_gui(codec, raw) + + print("\nDone.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index d99e432..a4acd9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "oshconnect" -version = "0.5.1a19" +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 = [ @@ -22,6 +22,19 @@ dependencies = [ "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.3.0", # pytest 9.x is the validated target. The suite uses no APIs that diff --git a/src/oshconnect/__init__.py b/src/oshconnect/__init__.py index a39bf67..4b677e2 100644 --- a/src/oshconnect/__init__.py +++ b/src/oshconnect/__init__.py @@ -35,12 +35,29 @@ ) 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 @@ -86,11 +103,25 @@ "QuantityRangeSchema", "TimeRangeSchema", "SWEDatastreamRecordSchema", + "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", 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/resources/base.py b/src/oshconnect/resources/base.py index 191854d..32dacab 100644 --- a/src/oshconnect/resources/base.py +++ b/src/oshconnect/resources/base.py @@ -384,13 +384,25 @@ def get_internal_id(self) -> UUID: """Return the local UUID. Alias for `get_streamable_id`.""" 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 + 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). """ - data_bytes = json.dumps(data).encode("utf-8") if isinstance(data, dict) else data - self._msg_writer_queue.put_nowait(data_bytes) + 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 diff --git a/src/oshconnect/resources/datastream.py b/src/oshconnect/resources/datastream.py index 8be4a1e..d8c0d80 100644 --- a/src/oshconnect/resources/datastream.py +++ b/src/oshconnect/resources/datastream.py @@ -25,6 +25,12 @@ 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 @@ -142,13 +148,79 @@ def _queue_push(self, msg): def _queue_pop(self): return self._msg_reader_queue.get_nowait() - def insert(self, data: dict): - """Encode ``data`` as JSON and publish it to this datastream's - observation MQTT topic. Bypasses the outbound deque.""" - # self._queue_push(data) - encoded = json.dumps(data).encode('utf-8') + 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 diff --git a/src/oshconnect/resources/system.py b/src/oshconnect/resources/system.py index ba89238..9bf2d6f 100644 --- a/src/oshconnect/resources/system.py +++ b/src/oshconnect/resources/system.py @@ -22,7 +22,11 @@ from ..csapi4py.constants import APIResourceTypes, ContentTypes from ..encoding import JSONEncoding from ..resource_datamodels import ControlStreamResource, DatastreamResource, SystemResource -from ..schema_datamodels import JSONCommandSchema, SWEDatastreamRecordSchema, SWEJSONCommandSchema +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 @@ -119,19 +123,52 @@ def name(self, value: str) -> None: ) 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 the SWE+JSON - record schema (``GET /datastreams/{id}/schema?obsFormat=application/swe+json``) - and cache it on ``_underlying_resource.record_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. + 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, @@ -142,23 +179,32 @@ def discover_datastreams(self) -> list[Datastream]: for ds in datastream_json: datastream_objs = DatastreamResource.model_validate(ds, by_alias=True) new_ds = Datastream(self._parent_node, datastream_objs) - try: - schema_resp = api.get_resource( - APIResourceTypes.DATASTREAM, datastream_objs.ds_id, - APIResourceTypes.SCHEMA, - params={'obsFormat': 'application/swe+json'}, - ) - schema_resp.raise_for_status() - new_ds._underlying_resource.record_schema = ( - SWEDatastreamRecordSchema.from_swejson_dict(schema_resp.json()) - ) - except Exception as e: + obs_format, parser = self._pick_datastream_schema_format( + datastream_objs.formats) + if obs_format is None: msg = ( - f"Failed to fetch SWE+JSON schema for datastream " - f"{datastream_objs.ds_id}: {type(e).__name__}: {e}" + f"Datastream {datastream_objs.ds_id} advertises no " + f"supported schema format (have: {datastream_objs.formats}); " + "skipping schema fetch." ) - logging.error(msg, exc_info=True) + 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]: diff --git a/src/oshconnect/schema_datamodels.py b/src/oshconnect/schema_datamodels.py index b8cb508..2bab7ca 100644 --- a/src/oshconnect/schema_datamodels.py +++ b/src/oshconnect/schema_datamodels.py @@ -12,7 +12,7 @@ from pydantic import BaseModel, Field, model_validator, HttpUrl, ConfigDict from .api_utils import Link, URI -from .encoding import JSONEncoding +from .encoding import BinaryEncoding, FlatBuffersEncoding, JSONEncoding, ProtobufEncoding from .geometry import Geometry from .swe_components import AnyComponent, check_named from .timemanagement import TimeInstant @@ -149,11 +149,15 @@ class SWEDatastreamRecordSchema(DatastreamRecordSchema): model_config = ConfigDict(populate_by_name=True) # 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", - "application/swe+binary", ] = Field(..., alias='obsFormat') encoding: JSONEncoding = Field(None) record_schema: AnyComponent = Field(..., alias='recordSchema') @@ -174,6 +178,129 @@ def from_swejson_dict(cls, data: dict) -> "SWEDatastreamRecordSchema": 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) + + @classmethod + 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, "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 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 @@ -348,7 +475,13 @@ class SystemHistoryProperties(BaseModel): # discriminator field — `obsFormat` / `commandFormat` — so validate and # dump round-trip without polymorphism quirks. AnyDatastreamRecordSchema = Annotated[ - Union[SWEDatastreamRecordSchema, OMJSONDatastreamRecordSchema], + Union[ + SWEDatastreamRecordSchema, + SWEBinaryDatastreamRecordSchema, + SWEProtobufDatastreamRecordSchema, + SWEFlatBuffersDatastreamRecordSchema, + OMJSONDatastreamRecordSchema, + ], Field(discriminator='obs_format'), ] """Public alias for `DatastreamResource.record_schema`. Discriminator: `obs_format`.""" @@ -367,4 +500,7 @@ class SystemHistoryProperties(BaseModel): 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/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 06b8cf1..9aa9e4d 100644 --- a/src/oshconnect/swe_components.py +++ b/src/oshconnect/swe_components.py @@ -95,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") @@ -110,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) 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/tests/test_discovery.py b/tests/test_discovery.py index 28e9639..3edbdf3 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -193,7 +193,7 @@ def schema_handler(ds_id): parent_node=node, resource_id="sys-1") with pytest.warns(SchemaFetchWarning, - match=r"Failed to fetch SWE\+JSON schema"): + match=r"Failed to fetch application/swe\+json schema"): discovered = sys.discover_datastreams() assert len(discovered) == 2 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_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_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/uv.lock b/uv.lock index 7d69f41..3ff947e 100644 --- a/uv.lock +++ b/uv.lock @@ -148,6 +148,30 @@ 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/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/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.18.0" @@ -380,6 +404,14 @@ wheels = [ { 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 = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { 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 = "frozenlist" version = "1.8.0" @@ -824,7 +856,7 @@ wheels = [ [[package]] name = "oshconnect" -version = "0.5.1a19" +version = "0.5.1a22" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, @@ -837,6 +869,10 @@ dependencies = [ ] [package.optional-dependencies] +av = [ + { name = "av" }, + { name = "pillow" }, +] dev = [ { name = "flake8" }, { name = "furo" }, @@ -849,6 +885,12 @@ dev = [ { name = "sphinx-copybutton" }, { name = "sphinxcontrib-mermaid" }, ] +flatbuffers = [ + { name = "flatbuffers" }, +] +protobuf = [ + { name = "protobuf" }, +] tinydb = [ { name = "tinydb" }, ] @@ -856,11 +898,15 @@ tinydb = [ [package.metadata] requires-dist = [ { 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 = "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" }, @@ -874,7 +920,7 @@ requires-dist = [ { 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" @@ -894,6 +940,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" }, ] +[[package]] +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.6.0" @@ -987,6 +1102,21 @@ wheels = [ { 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" From 7ccb574ea411324201f6eddd938b1e9ddb55cfd1 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Thu, 21 May 2026 17:07:16 -0500 Subject: [PATCH 28/29] add support for new mqtt topics supporting content types --- .../csapi4py/default_api_helpers.py | 9 +- src/oshconnect/csapi4py/mqtt.py | 37 ++++ src/oshconnect/resources/base.py | 8 +- src/oshconnect/resources/controlstream.py | 18 +- src/oshconnect/resources/datastream.py | 11 +- tests/test_mqtt_topics.py | 164 +++++++++++++++++- 6 files changed, 235 insertions(+), 12 deletions(-) diff --git a/src/oshconnect/csapi4py/default_api_helpers.py b/src/oshconnect/csapi4py/default_api_helpers.py index 6e25ded..ed4a40e 100644 --- a/src/oshconnect/csapi4py/default_api_helpers.py +++ b/src/oshconnect/csapi4py/default_api_helpers.py @@ -14,6 +14,7 @@ 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. @@ -291,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 @@ -303,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 de2a15a..554d421 100644 --- a/src/oshconnect/csapi4py/mqtt.py +++ b/src/oshconnect/csapi4py/mqtt.py @@ -4,6 +4,43 @@ 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): diff --git a/src/oshconnect/resources/base.py b/src/oshconnect/resources/base.py index 32dacab..2e9c740 100644 --- a/src/oshconnect/resources/base.py +++ b/src/oshconnect/resources/base.py @@ -217,13 +217,16 @@ def init_mqtt(self): 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): + 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 @@ -262,7 +265,8 @@ def get_mqtt_topic(self, subresource: APIResourceTypes | None = None, data_topic 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) + resource_type=parent_res_type, data_topic=data_topic, + format=format) return topic def get_event_topic(self) -> str: diff --git a/src/oshconnect/resources/controlstream.py b/src/oshconnect/resources/controlstream.py index da0dcf6..c6827e5 100644 --- a/src/oshconnect/resources/controlstream.py +++ b/src/oshconnect/resources/controlstream.py @@ -66,13 +66,23 @@ def get_id(self) -> str: return self._underlying_resource.cs_id def init_mqtt(self): - """Set ``self._topic`` to the control stream's command data topic.""" + """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() - self._topic = self.get_mqtt_topic(subresource=APIResourceTypes.COMMAND, data_topic=True) + 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``).""" - return self.get_mqtt_topic(subresource=APIResourceTypes.STATUS, data_topic=True) + """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) diff --git a/src/oshconnect/resources/datastream.py b/src/oshconnect/resources/datastream.py index d8c0d80..91524a9 100644 --- a/src/oshconnect/resources/datastream.py +++ b/src/oshconnect/resources/datastream.py @@ -133,9 +133,16 @@ def start(self): def init_mqtt(self): """Set ``self._topic`` to the datastream's observation data topic - (CS API Part 3 ``:data`` suffix).""" + (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() - self._topic = self.get_mqtt_topic(subresource=APIResourceTypes.OBSERVATION, data_topic=True) + 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( diff --git a/tests/test_mqtt_topics.py b/tests/test_mqtt_topics.py index 30e6676..88bc145 100644 --- a/tests/test_mqtt_topics.py +++ b/tests/test_mqtt_topics.py @@ -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,7 +158,7 @@ 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): @@ -310,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" From a360f3c512a381fdb193054dd973ff1b9636d492 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Tue, 2 Jun 2026 15:46:32 -0500 Subject: [PATCH 29/29] add some examples and update documentation --- .gitignore | 3 + docs/source/architecture/events.md | 9 +- docs/source/index.rst | 3 +- docs/source/tutorial.rst | 48 +- examples/AXIS_MQTT_STREAM_README.md | 171 ++++++ examples/axis_video_frame.py | 405 ++++++++----- examples/axis_video_mqtt_stream.py | 908 ++++++++++++++++++++++++++++ 7 files changed, 1397 insertions(+), 150 deletions(-) create mode 100644 examples/AXIS_MQTT_STREAM_README.md create mode 100644 examples/axis_video_mqtt_stream.py diff --git a/.gitignore b/.gitignore index 1f1ee20..011106b 100644 --- a/.gitignore +++ b/.gitignore @@ -167,3 +167,6 @@ 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/docs/source/architecture/events.md b/docs/source/architecture/events.md index 188b172..33518c1 100644 --- a/docs/source/architecture/events.md +++ b/docs/source/architecture/events.md @@ -3,8 +3,13 @@ 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; ControlStreams publish - commands. Implemented via `paho-mqtt` in `csapi4py/mqtt.py`. + `: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 diff --git a/docs/source/index.rst b/docs/source/index.rst index f2d86fc..101467c 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -9,7 +9,8 @@ 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 +- 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) diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index 6f3d28d..1a96dbb 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -161,6 +161,47 @@ 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) ------------------------------ Once a node is configured with MQTT and datastreams are discovered, start receiving @@ -593,9 +634,10 @@ section) to see whether the system accepted and executed it. Subscribing to Command Status ----------------------------- -Control streams emit two MQTT topics: ``:commands`` (input) and ``:status`` -(output, where the system reports execution results). Subscribe to status -updates: +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 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 index d39a4c0..fb5ae92 100644 --- a/examples/axis_video_frame.py +++ b/examples/axis_video_frame.py @@ -8,26 +8,31 @@ """End-to-end fidelity check for the SWE+binary codec against a live OSH node. -Hits an Axis-camera-backed OSH datastream, pulls H.264 frames as +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 side-by-side tkinter window comparing: +**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``. -* the H.264 frame decoded from the *raw* bytes the OSH node sent, and -* the H.264 frame decoded after a full encode→decode roundtrip through - ``SWEBinaryCodec`` + ``encode_swe_binary_blob``. - -If the codec is faithful, the two panels are pixel-identical and the -verdict label reads "Byte-for-byte identical". Any divergence shows up -visually and in the printed byte-comparison. +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`` (the Axis test node) -* Datastream: ``040g`` (the ``video1`` output) -* Frames: ``30`` (enough to land at least one keyframe in practice) +* 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: -Override with the env vars ``OSHC_AXIS_PORT``, ``OSHC_AXIS_DS``, and -``OSHC_AXIS_FRAMES`` respectively. +* ``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 --- @@ -47,7 +52,9 @@ import os import struct import sys +from dataclasses import dataclass from pathlib import Path +from typing import Optional import requests @@ -60,12 +67,52 @@ # --------------------------------------------------------------------------- PORT = os.environ.get("OSHC_AXIS_PORT", "9191") -DS_ID = os.environ.get("OSHC_AXIS_DS", "040g") 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 # --------------------------------------------------------------------------- @@ -82,9 +129,9 @@ def hex_window(label: str, raw: bytes, head: int = 16, tail: int = 8) -> None: print(f" {label} ({len(raw)} B): {raw[:head].hex()}…{raw[-tail:].hex()}") -def fetch_schema() -> SWEBinaryDatastreamRecordSchema: +def fetch_schema(ds_id: str) -> SWEBinaryDatastreamRecordSchema: resp = requests.get( - f"{BASE_URL}/datastreams/{DS_ID}/schema", + f"{BASE_URL}/datastreams/{ds_id}/schema", params={"obsFormat": "application/swe+binary"}, timeout=5, ) @@ -92,9 +139,9 @@ def fetch_schema() -> SWEBinaryDatastreamRecordSchema: return SWEBinaryDatastreamRecordSchema.from_swebinary_dict(resp.json()) -def fetch_observations(limit: int) -> bytes: +def fetch_observations(ds_id: str, limit: int) -> bytes: resp = requests.get( - f"{BASE_URL}/datastreams/{DS_ID}/observations", + f"{BASE_URL}/datastreams/{ds_id}/observations", params={"f": "application/swe+binary", "limit": limit}, timeout=10, ) @@ -107,20 +154,20 @@ def fetch_observations(limit: int) -> bytes: # --------------------------------------------------------------------------- -def compare_round_trip(codec: SWEBinaryCodec, raw: bytes) -> bytes: - """Decode → re-encode the first record; print + assert byte-identity. +def compare_round_trip(label: str, codec: SWEBinaryCodec, raw: bytes) -> bool: + """Decode → re-encode the first record; print + return byte-identity flag. - Returns the H.264 NAL bytes for the first decoded record so the caller - can save them. + 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("\n=== Round-trip fidelity check (first record) ===") + 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)") - # Re-encode with our codec reencoded = encode_swe_binary_blob(decoded["img"], ts=decoded["time"]) original_window = raw[:end] @@ -128,19 +175,18 @@ def compare_round_trip(codec: SWEBinaryCodec, raw: bytes) -> bytes: hex_window("from node", original_window) hex_window("our codec", reencoded) if original_window == reencoded: - print("\n✓ Byte-for-byte identical.") - else: - print("\n✗ 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 decoded["img"] + 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: @@ -156,10 +202,27 @@ def save_nal_stream(codec: SWEBinaryCodec, raw: bytes, out_path: Path) -> int: f.write(rec["img"]) total += len(rec["img"]) count += 1 - print(f"\nWrote {count} NAL units ({total} bytes) → {out_path}") + 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. @@ -183,18 +246,85 @@ def _decode_first_frame(nal_bytes: bytes): return None -def show_side_by_side_gui(codec: SWEBinaryCodec, raw: bytes) -> None: - """Show side-by-side: frame as decoded from the OSH node's raw wire bytes - vs. frame as decoded after a full encode→decode round-trip through our codec. +# --------------------------------------------------------------------------- +# 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}") - Walks every record in `raw` to build two parallel NAL streams (one - direct, one through the codec). Decodes the first frame of each and - presents them in a tkinter window with a match/mismatch verdict. + 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 (PyAV needed for _decode_first_frame) + import av # noqa: F401 from PIL import Image, ImageTk # type: ignore except ImportError as exc: print("\n(GUI display needs PyAV + Pillow + tkinter:") @@ -202,72 +332,73 @@ def show_side_by_side_gui(codec: SWEBinaryCodec, raw: bytes) -> None: print(" Install via: uv pip install -e '.[av]')") return - print("\n=== Building parallel NAL streams (node vs. codec) ===") - 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"] - # Round-trip through our codec, then re-decode to extract the NAL. - 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 - print(f" {n_records} records → {len(node_nals)} bytes per stream") - identical = bytes(node_nals) == bytes(codec_nals) - print(f" NAL streams identical: {identical}") - - print("Decoding first frame of each stream with PyAV…") - frame_node = _decode_first_frame(bytes(node_nals)) - frame_codec = _decode_first_frame(bytes(codec_nals)) - if frame_node is None or frame_codec is None: - print(" could not decode at least one stream; skipping GUI.") + 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 - h, w = frame_node.shape[:2] - # Resize so the side-by-side fits a typical laptop screen (~1400 px wide). - target_w = 600 - scale = min(1.0, target_w / w) - new_size = (max(1, int(w * scale)), max(1, int(h * scale))) - root = tk.Tk() - root.title("OSH camera — SWE+binary codec fidelity") - + root.title("OSH cameras — SWE+binary codec fidelity") container = tk.Frame(root, padx=12, pady=12) container.pack() - header_text = ( - f"Datastream {DS_ID} · {n_records} records · " - f"{w}×{h} → display {new_size[0]}×{new_size[1]}" - ) - tk.Label(container, text=header_text, font=("Helvetica", 11)).grid( + # 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", 12, "bold")).grid(row=1, column=0, padx=6) + font=("Helvetica", 11, "bold")).grid(row=1, column=0, padx=6) tk.Label(container, text="Through OSHConnect codec\n(decode → encode → decode)", - font=("Helvetica", 12, "bold")).grid(row=1, column=1, padx=6) - - # Keep refs alive on the root or they're garbage-collected before render. - root._img_node = ImageTk.PhotoImage(Image.fromarray(frame_node).resize(new_size)) - root._img_codec = ImageTk.PhotoImage(Image.fromarray(frame_codec).resize(new_size)) - tk.Label(container, image=root._img_node, borderwidth=2, relief="solid").grid( - row=2, column=0, padx=6, pady=4) - tk.Label(container, image=root._img_codec, borderwidth=2, relief="solid").grid( - row=2, column=1, padx=6, pady=4) - - verdict = "✓ Byte-for-byte identical" if identical else "✗ Mismatch" - color = "#1b8a3a" if identical else "#b1331e" - tk.Label(container, text=f"NAL stream verdict: {verdict}", - font=("Helvetica", 12, "bold"), fg=color).grid( - row=3, column=0, columnspan=2, pady=(10, 0)) - - tk.Label(container, - text="Close the window to exit.", + 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=4, column=0, columnspan=2, pady=(4, 0)) + row=grid_row, column=0, columnspan=2, pady=(4, 0)) root.mainloop() @@ -278,50 +409,36 @@ def show_side_by_side_gui(codec: SWEBinaryCodec, raw: bytes) -> None: def main() -> int: - print(f"Hitting {BASE_URL}/datastreams/{DS_ID}") - - try: - schema = fetch_schema() - except Exception as exc: - print(f"ERROR: could not fetch schema: {exc}") - return 1 - print("✓ Fetched swe+binary schema") - members = [m.ref for m in schema.record_encoding.members] - print(f" members: {members}") - - codec = SWEBinaryCodec(schema) - - try: - raw = fetch_observations(limit=N_FRAMES) - except Exception as exc: - print(f"ERROR: could not fetch observations: {exc}") - return 1 - print(f"✓ Fetched {len(raw)} bytes ({N_FRAMES} requested)") - - # Round-trip the first record - try: - compare_round_trip(codec, raw) - except Exception as exc: - print(f"ERROR: round-trip failed: {exc}") - return 1 - - # Save the full NAL stream - h264_path = OUT_DIR / "axis_frames.h264" - try: - save_nal_stream(codec, raw, h264_path) - except struct.error as exc: - print(f"WARNING: could not walk all records ({exc}) — partial file written") - except Exception as exc: - print(f"WARNING: error while saving NAL stream: {exc}") - - # Pop the side-by-side comparison GUI. Blocks until the user closes - # the window; skipped automatically when PyAV/Pillow/tkinter aren't - # available. - show_side_by_side_gui(codec, raw) - + 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 + return 0 if all(r.error is None and r.identical for r in results) else 2 if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + 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