From a526cae2745c92e66e7b1f8e7059904c26096209 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 1 May 2026 20:39:04 -0500 Subject: [PATCH 01/33] 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 a389f6a659b6414b59f27227dd111785a36aa548 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 1 May 2026 20:56:20 -0500 Subject: [PATCH 02/33] 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 6df349addf7368e369b0147e5e3ddcfa13d1c00e Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 1 May 2026 21:59:42 -0500 Subject: [PATCH 03/33] 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 344bf365dca11b66fb16a4c9cd338e8c1954562d Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 1 May 2026 22:39:07 -0500 Subject: [PATCH 04/33] 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 0df58101258dabdfe2b516603afdc8f8b9daf90c Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 1 May 2026 23:17:40 -0500 Subject: [PATCH 05/33] 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 3d91f4a057bd79ed9b78eba023dd7c288d6dc807 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 1 May 2026 23:21:08 -0500 Subject: [PATCH 06/33] 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 79e788860984f4322027ef603d18c4bf0e953c6a Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 1 May 2026 23:31:16 -0500 Subject: [PATCH 07/33] 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 51e042bee06cbc8246c1ebf9fa01ae6f258113ff Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 1 May 2026 23:45:59 -0500 Subject: [PATCH 08/33] 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 bc50bae7b8a005515362a7afe608f00ef55a28e8 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 1 May 2026 23:58:46 -0500 Subject: [PATCH 09/33] 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 46effa72d288394d2ee11c278d3b349bfab310b6 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Sat, 2 May 2026 00:05:03 -0500 Subject: [PATCH 10/33] 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 175e114cab240c50ab34095d744520e96210e697 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Sat, 2 May 2026 00:12:49 -0500 Subject: [PATCH 11/33] 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 40e9c0bf48e121c4e2cf63fc1ffcaef6779a296c Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Tue, 5 May 2026 15:49:23 -0500 Subject: [PATCH 12/33] 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 2fdb2f06867ba710fcee3e4bae4cb70d6d3cb69d Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Tue, 5 May 2026 15:59:16 -0500 Subject: [PATCH 13/33] 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 5e8f26a1a7b2b835ba556196e2b8d84469693834 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Tue, 5 May 2026 22:36:35 -0500 Subject: [PATCH 14/33] 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 605065d1ac29e262d0416fbdaf2ed16e44427e75 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Wed, 6 May 2026 13:06:22 -0500 Subject: [PATCH 15/33] 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 70e9da71eb0101489432ff28b56fe9950bf18e2f Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Wed, 6 May 2026 14:48:13 -0500 Subject: [PATCH 16/33] 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 057d024dd335a5aa5bd6b4f7b3e5aa228abd5add Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Wed, 6 May 2026 14:49:00 -0500 Subject: [PATCH 17/33] 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 c0d23cb3474e37e2837e5054b971e18f3cd919dd Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Wed, 6 May 2026 22:26:03 -0500 Subject: [PATCH 18/33] 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 d008fb79f57506258a35e0b931085d8528b397c1 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Thu, 7 May 2026 21:30:54 -0500 Subject: [PATCH 19/33] 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 1c24bcf7c7862246ab8a44f7eb5f2ed458c27326 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Thu, 7 May 2026 22:04:36 -0500 Subject: [PATCH 20/33] 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 af569fac27f5d1957f7e203fda0c1237ad597487 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Thu, 7 May 2026 22:54:02 -0500 Subject: [PATCH 21/33] 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 27ac30e140820d112e57082cc9e2642da8879cc7 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 8 May 2026 01:33:32 -0500 Subject: [PATCH 22/33] 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 3be12789fe7c8bc063c2dd490c60bbe59a75cc45 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 8 May 2026 01:52:11 -0500 Subject: [PATCH 23/33] 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 d5ce6c864b009f40ddc791d819456126d27a6d5b Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 8 May 2026 02:38:43 -0500 Subject: [PATCH 24/33] 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 40ce1875ce5206e2a27b4adb093c1a8cc28a7779 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 8 May 2026 02:52:28 -0500 Subject: [PATCH 25/33] 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 3be14b8bfaea10991ac4f21730e3bdcc1ca96382 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Tue, 19 May 2026 10:29:53 -0500 Subject: [PATCH 26/33] 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 9cbdd07716220d90d2c25904924dc9ff75cfa456 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Tue, 19 May 2026 23:49:51 -0500 Subject: [PATCH 27/33] 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 08796912c9b42779ec9aa0ecda0bf9ea23f50e0b Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Thu, 21 May 2026 17:07:16 -0500 Subject: [PATCH 28/33] 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 20cb1c136cbd3a7ed367aec77312a9b50d4efefc Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Tue, 2 Jun 2026 15:46:32 -0500 Subject: [PATCH 29/33] 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 From 644aa8d6ed88735a731b2c0548983a454a1304cf Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Tue, 16 Jun 2026 15:02:22 -0500 Subject: [PATCH 30/33] WIP: binary encodings and modularization snapshot Work-in-progress snapshot pushed to local Forgejo for backup/sharing. Includes csapi4py module pruning, swe+binary/protobuf work, resource split, and test updates. --- docs/source/tutorial.rst | 188 ++- examples/swe_proto_node_check.py | 762 +++++++++++ src/oshconnect/csapi4py/model_utils.py | 10 - src/oshconnect/csapi4py/mqtt.py | 1 + src/oshconnect/csapi4py/querymodel.py | 25 - src/oshconnect/csapi4py/request_bodies.py | 99 -- src/oshconnect/csapi4py/sml.py | 51 - src/oshconnect/encoding.py | 10 +- src/oshconnect/network_properties.py | 10 - src/oshconnect/resources/datastream.py | 10 +- src/oshconnect/resources/system.py | 10 +- src/oshconnect/schema_datamodels.py | 173 ++- src/oshconnect/swe_protobuf.py | 1398 +++++++++++++-------- tests/helpers.py | 126 ++ tests/test_api_helpers_auth.py | 88 +- tests/test_con_sys_api.py | 134 +- tests/test_controlstream_insert_schema.py | 34 +- tests/test_csapi_serialization.py | 116 +- tests/test_datastore.py | 51 +- tests/test_default_api_helpers.py | 110 +- tests/test_discovery.py | 38 +- tests/test_imports.py | 4 + tests/test_mqtt_topics.py | 46 +- tests/test_node.py | 24 - tests/test_node_to_node_sync.py | 21 +- tests/test_oshconnect.py | 49 +- tests/test_swe_binary.py | 57 +- tests/test_swe_components.py | 52 +- tests/test_swe_protobuf.py | 1036 +++++++++++---- 29 files changed, 3028 insertions(+), 1705 deletions(-) create mode 100644 examples/swe_proto_node_check.py delete mode 100644 src/oshconnect/csapi4py/model_utils.py delete mode 100644 src/oshconnect/csapi4py/querymodel.py delete mode 100644 src/oshconnect/csapi4py/request_bodies.py delete mode 100644 src/oshconnect/csapi4py/sml.py delete mode 100644 src/oshconnect/network_properties.py create mode 100644 tests/helpers.py delete mode 100644 tests/test_node.py diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index 1a96dbb..f6591c3 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -413,9 +413,19 @@ 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. +messages encoded against a **per-datastream descriptor**. Each +DataStream carries a pre-compiled Protobuf schema — a serialized +``google.protobuf.FileDescriptorSet`` describing one +``Observation_``-shaped message — and every observation is a +serialized instance of that message. ``application/swe+flatbuffers`` is +the FlatBuffers analogue. + +The per-datastream message has a fixed envelope at fields 1–5 +(``id``, ``datastream_id``, ``foi_id``, ``phenomenon_time``, +``result_time`` — the two times are ``google.protobuf.Timestamp``) +followed by the SWE Common record at fields 6+, one flat field per +component. SWE semantics (definition, label, unit) travel as field +options inside the descriptor. Why a separate encoding family from SWE+Binary? @@ -423,80 +433,144 @@ Why a separate encoding family from SWE+Binary? 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. +* **SWE+Protobuf** is a self-describing tag-length-value stream whose + layout comes from the delivered descriptor. The receiver registers the + ``FileDescriptorSet`` in a ``DescriptorPool`` and builds the message + class dynamically — no ``protoc`` and no code-generated bindings. Install requirements ~~~~~~~~~~~~~~~~~~~~ -Install the optional extra and generate the bindings from BinaryEncodings: +Install the optional extra — only the ``protobuf`` runtime is needed +(the per-datastream message is built dynamically from the descriptor, so +no generated SWE Common bindings are required): .. 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. +The codec imports the ``protobuf`` runtime lazily on first use and +raises a descriptive ``ImportError`` (including the install hint) if the +extra is not installed. Encoding and decoding observations ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``Datastream.insert(...)`` and ``decode_observation(...)`` dispatch on -the schema's ``obs_format`` exactly as they do for SWE+Binary: +the schema's ``obs_format`` exactly as they do for SWE+Binary. The +schema carries the descriptor; during discovery it is fetched from +``/datastreams/{id}/schema`` and parsed into a +`SWEProtobufDatastreamRecordSchema`: .. code-block:: python - from oshconnect import ( - DataRecordSchema, TimeSchema, QuantitySchema, CountSchema, - BooleanSchema, TextSchema, - SWEProtobufDatastreamRecordSchema, - ) - from oshconnect.api_utils import URI, UCUMCode + from oshconnect import SWEProtobufDatastreamRecordSchema, SWEProtobufCodec - 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')), - ], + # `fds_bytes` is a serialized google.protobuf.FileDescriptorSet for the + # per-datastream observation message (delivered by the OSH node, or + # compiled from a .proto with `protoc --include_imports + # --descriptor_set_out`). + schema = SWEProtobufDatastreamRecordSchema( + file_descriptor_set=fds_bytes, + message_type="org.example.WeatherObservation", # optional if the set has one message ) - 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``. + # Now `Datastream.insert({...})` packs the result record via + # SWEProtobufCodec (stamping datastream_id + result_time into the + # envelope) and `Datastream.decode_observation(raw)` returns the + # result record dict. + +``insert`` / ``decode_observation`` operate on the **result record** +(fields 6+), keyed by field name — the same dict shape the SWE+Binary +codec uses and what lands in ``ObservationResource.result``. The +envelope metadata (ids and the two timestamps) is supplied by the +``Datastream`` on encode and recoverable on decode via +``SWEProtobufCodec.decode_with_envelope(raw)``. + +Supported result-field types: Protobuf scalars (numbers, ``bool``, +``string``, ``bytes``), ``google.protobuf.Timestamp`` (decoded to an ISO +8601 string), and **nested records and vectors** — these recurse into +nested dicts (``{"location": {"lat": …, "lon": …}}``); a vector may be +given as a sequence on encode and comes back as a dict keyed by +coordinate name. ``DataArray`` (repeated) fields are not yet supported +and raise ``NotImplementedError``. + +Generating a swe+proto schema (create side) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When *producing* a datastream you usually have the record structure in +SWE Common already (the ``record_schema`` your SWE+JSON or SWE+Binary +datastream carries). Translate it into a swe+proto schema — the +descriptor is generated for you (the inverse of OSH's +``ProtoSchemaWriter``: envelope fields 1–5 plus the record's components +mapped to flat result fields 6+): + +.. code-block:: python + + from oshconnect import SWEProtobufDatastreamRecordSchema + + # From a SWE Common DataRecord directly... + proto_schema = SWEProtobufDatastreamRecordSchema.from_record_schema( + record, message_name=f"Observation_{ds_id}") + + # ...or from another datastream schema you already hold (its semantic + # record_schema is reused, so SWE+JSON / SWE+CSV / SWE+binary all map + # to the same descriptor): + proto_schema = SWEProtobufDatastreamRecordSchema.from_other_schema(swe_binary_schema) + +Editing the schema as ``.proto`` text +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The generated schema is a binary descriptor, but you can translate it to +editable ``.proto`` **source text** — inspect it, hand-tweak it (rename +or add fields, change types, add annotations), then compile it back: + +.. code-block:: python + + schema = SWEProtobufDatastreamRecordSchema.from_record_schema(record) + + # Render the carried descriptor as .proto source (no protoc needed). + # Works for node-delivered schemas too — it renders whatever descriptor + # the schema holds. + text = schema.to_proto_source() + + # ... edit `text` as needed ... + + # Recompile the edited source back into a schema (requires protoc on PATH; + # the from_record_schema path itself needs no protoc). + edited = SWEProtobufDatastreamRecordSchema.from_proto_source(text) + +``to_proto_source`` is a faithful rendering of the descriptor (not a +second generator), so the text and the binary descriptor never drift. +``from_proto_source`` shells out to ``protoc`` and defaults the message +type to the first message in the compiled file. + +Component → field type mapping: Quantity → ``double``, Count → +``int32``, Boolean → ``bool``, Time → ``google.protobuf.Timestamp`` +(ISO) or ``double`` (numeric), Text → ``string``. A **Category** maps to +``string`` when unconstrained, or to a proto **enum** (``Enum_``, +tokens numbered from 0 — matching OSH's convention) when it carries an +``AllowedTokens`` constraint; encode accepts the token string and decode +returns it. The component's OGC ``dataType`` selects the numeric width +(float32 → ``float``, signedLong → ``int64``, …) when known. **Nested +records and vectors** become nested message types (``Rec`` / +``Vec``, inner fields numbered from 1), recursed to arbitrary depth. A +**DataArray** becomes the node's ``Array { repeated = 1 }`` +wrapper (the element may be a scalar, record, vector, or constrained +category), round-tripping as ``{array_name: {element_name: [...]}}``. +Component names that aren't valid proto identifiers (e.g. SWE NameToken +hyphens) are sanitized to underscores; enum tokens must already be valid +identifiers (as the node requires). The remaining composites +(``DataChoice``, ranges, geometry, matrices) are not yet translatable and +raise ``NotImplementedError``. + +.. note:: + + The JSON envelope that delivers the descriptor over the schema + endpoint is a contract still being finalized with the OSH node side; + OSHConnect currently assumes + ``{"obsFormat", "messageType", "fileDescriptorSet": }``. See + ``docs/osh_spec_deviations.md`` (``swe-proto-descriptor-format``). FlatBuffers status ~~~~~~~~~~~~~~~~~~ diff --git a/examples/swe_proto_node_check.py b/examples/swe_proto_node_check.py new file mode 100644 index 0000000..01e7b7f --- /dev/null +++ b/examples/swe_proto_node_check.py @@ -0,0 +1,762 @@ +#!/usr/bin/env python +# ============================================================================= +# Copyright (c) 2026 Georobotix Innovative Research +# Date: 2026/6/11 +# Author: Ian Patterson +# Contact Email: ian.patterson@georobotix.us +# ============================================================================= + +"""Diagnostic check of ``application/swe+proto`` against a live OSH node. + +Runs every swe+proto-advertising datastream on the node through a staged +pipeline and reports, per stage, whether the failure (if any) is on the +node side or the client side: + +0. **advertised** — the datastream lists ``application/swe+proto`` in + its ``formats`` array. A datastream that *serves* the format on its + schema endpoint but omits it from ``formats`` is still tested, with + this stage marked FAIL (node-side advertisement regression). +1. **schema-fetch** — ``GET /datastreams/{id}/schema?obsFormat=application/swe+proto``. + An HTTP error here is node-side (consys-proto module missing, format + not registered, or a server exception). +2. **schema-parse** — parse the JSON envelope + (``{"obsFormat", "messageType", "fileDescriptorSet": }``) into + `SWEProtobufDatastreamRecordSchema` and build a `SWEProtobufCodec` + from the delivered ``FileDescriptorSet``. A failure here is a contract + drift between the node's schema document and this client. The decoded + ``.proto`` source is written to the log file for inspection. +3. **http-fetch** — ``GET /datastreams/{id}/observations?f=application/swe+proto``. +4. **decode** — split the response into varint-length-delimited + frames (the node writes each observation with protobuf + ``writeDelimitedTo``) and decode each with the codec. Decoded + observations are logged as pretty JSON; a frame that fails to decode + gets a hex dump in the log file so the bytes can be diffed against + the descriptor. +5. **cross-check** — fetch the same observations as + ``application/om+json`` and compare result values field-by-field + (matched by ``phenomenonTime``). A mismatch means the proto wire type + or field mapping has drifted from the JSON truth. +6. **mqtt** — subscribe to the CS API Part 3 + ``…/observations:data/swe-proto`` topic, collect a few live messages, + and decode them the same way. Requires the node's MQTT service + (default broker port 1883). Skippable via ``OSHC_PROTO_MQTT_SECS=0``. + +Console output is a concise INFO narrative plus decoded samples; the log +file (``swe_proto_node_check.log`` beside this script by default) gets +DEBUG detail — full tracebacks, raw hex previews, and the rendered +``.proto`` source — so a failed run is attributable without re-running. + +Defaults +-------- +* Node: ``http://localhost:8282/sensorhub/api`` (HTTP) +* ``localhost:1883`` (MQTT broker on the same host) + +Override with: + +* ``OSHC_PROTO_HOST`` — server hostname/IP (default ``localhost``). +* ``OSHC_PROTO_PORT`` — HTTP API port (default ``8282``). +* ``OSHC_PROTO_MQTT_PORT`` — MQTT broker port (default ``1883``). +* ``OSHC_PROTO_USER`` / ``OSHC_PROTO_PASS`` — Basic-Auth credentials, if any. +* ``OSHC_PROTO_OBS_COUNT`` — observations to request per HTTP fetch (default ``5``). +* ``OSHC_PROTO_MQTT_SECS`` — seconds to listen per datastream on MQTT + (default ``10``; ``0`` skips the MQTT stage). +* ``OSHC_PROTO_MQTT_MSGS`` — stop listening early after this many + messages (default ``5``). +* ``OSHC_PROTO_LOG`` — log file path (default beside this script). + +Run +--- + uv run python examples/swe_proto_node_check.py + +Exit codes: ``0`` all stages passed (skips allowed), ``1`` at least one +stage failed, ``2`` node unreachable / nothing to test. +""" +from __future__ import annotations + +import json +import logging +import math +import os +import sys +import time +import traceback +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any, Optional + +import requests + +from oshconnect import OSHConnect +from oshconnect.csapi4py.constants import APIResourceTypes +from oshconnect.node import Node +from oshconnect.resources.base import StreamableModes +from oshconnect.resources.datastream import Datastream +from oshconnect.schema_datamodels import SWEProtobufDatastreamRecordSchema +from oshconnect.swe_protobuf import SWEProtobufCodec + +SWE_PROTO = "application/swe+proto" + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +HOST = os.environ.get("OSHC_PROTO_HOST", "localhost") +HTTP_PORT = int(os.environ.get("OSHC_PROTO_PORT", "8282")) +MQTT_PORT = int(os.environ.get("OSHC_PROTO_MQTT_PORT", "1883")) +USER = os.environ.get("OSHC_PROTO_USER") or None +PASS = os.environ.get("OSHC_PROTO_PASS") or None +OBS_COUNT = int(os.environ.get("OSHC_PROTO_OBS_COUNT", "5")) +MQTT_SECS = float(os.environ.get("OSHC_PROTO_MQTT_SECS", "10")) +MQTT_MSGS = int(os.environ.get("OSHC_PROTO_MQTT_MSGS", "5")) +LOG_PATH = Path( + os.environ.get("OSHC_PROTO_LOG", "") + or str(Path(__file__).with_name("swe_proto_node_check.log"))) + +log = logging.getLogger("swe_proto_check") + + +def setup_logging() -> None: + """Console = concise INFO narrative; log file = DEBUG forensics. + + The file handler gets everything (tracebacks, hex dumps, the rendered + ``.proto`` source) so a failed run can be diagnosed without re-running + with a different verbosity. + """ + root = logging.getLogger() + root.setLevel(logging.DEBUG) + + console = logging.StreamHandler(sys.stdout) + console.setLevel(logging.INFO) + console.setFormatter(logging.Formatter("%(levelname)-7s %(message)s")) + root.addHandler(console) + + filehandler = logging.FileHandler(LOG_PATH, mode="w", encoding="utf-8") + filehandler.setLevel(logging.DEBUG) + filehandler.setFormatter(logging.Formatter( + "%(asctime)s %(levelname)-7s %(name)s: %(message)s")) + root.addHandler(filehandler) + + # paho/urllib3 DEBUG spam stays out of the file unless explicitly wanted. + logging.getLogger("paho").setLevel(logging.WARNING) + logging.getLogger("urllib3").setLevel(logging.WARNING) + logging.getLogger("oshconnect").setLevel(logging.INFO) + + +def hexdump(data: bytes, limit: int = 64) -> str: + """Short hex preview of a payload for the log file.""" + head = data[:limit] + body = " ".join(f"{b:02x}" for b in head) + more = f" … (+{len(data) - limit} bytes)" if len(data) > limit else "" + return f"[{len(data)} bytes] {body}{more}" + + +# --------------------------------------------------------------------------- +# Varint-delimited framing +# --------------------------------------------------------------------------- + + +def split_delimited(buf: bytes) -> list[bytes]: + """Split a buffer of protobuf varint-length-delimited messages. + + The node emits each observation with ``Message.writeDelimitedTo`` (see + ``ObsBindingProto.java``), so both the HTTP batch body and each MQTT + payload are ```` frames back to back. + + :raises ValueError: on a truncated varint or a frame that runs past + the end of the buffer — both indicate corruption in transit or a + framing change on the node side. + """ + frames: list[bytes] = [] + i = 0 + while i < len(buf): + length = 0 + shift = 0 + start = i + while True: + if i >= len(buf): + raise ValueError( + f"truncated varint length prefix at offset {start}") + byte = buf[i] + i += 1 + length |= (byte & 0x7F) << shift + if not byte & 0x80: + break + shift += 7 + if shift > 35: + raise ValueError( + f"varint length prefix at offset {start} exceeds 5 bytes " + "— payload is probably not delimited protobuf") + if i + length > len(buf): + raise ValueError( + f"frame at offset {start} declares {length} bytes but only " + f"{len(buf) - i} remain — truncated response?") + frames.append(buf[i:i + length]) + i += length + return frames + + +def sniff_json(payload: bytes) -> Optional[Any]: + """Return the parsed document if ``payload`` is actually JSON text. + + The single most diagnostic failure mode: asking for swe+proto and + getting JSON back means the node ignored (or doesn't implement) + content negotiation for that path — a node-side break, not a codec + bug. Detect it explicitly instead of letting the protobuf parser + report a generic "wire format corrupt". + """ + head = payload.lstrip()[:1] + if head not in (b"{", b"["): + return None + try: + return json.loads(payload.decode("utf-8")) + except (UnicodeDecodeError, ValueError): + return None + + +def decode_payload(codec: SWEProtobufCodec, payload: bytes) -> list[dict]: + """Decode one wire payload into observation dicts (with envelope). + + Tries varint-delimited framing first (the node's ``writeDelimitedTo`` + form); falls back to treating the payload as a single bare message. + Logs which framing succeeded at DEBUG so a framing change on the node + is visible in the log file. + """ + try: + frames = split_delimited(payload) + decoded = [codec.decode_with_envelope(f) for f in frames] + log.debug("payload decoded as %d delimited frame(s)", len(frames)) + return decoded + except Exception as delimited_exc: # noqa: BLE001 + log.debug("delimited decode failed (%s); trying bare message", + delimited_exc) + decoded = [codec.decode_with_envelope(payload)] + log.debug("payload decoded as a single bare (non-delimited) message") + return decoded + + +# --------------------------------------------------------------------------- +# Stage bookkeeping +# --------------------------------------------------------------------------- + +STAGES = ("advertised", "schema-fetch", "schema-parse", "http-fetch", + "decode", "cross-check", "mqtt") + + +@dataclass +class DatastreamReport: + """Per-datastream stage outcomes: PASS / FAIL / SKIP per stage.""" + ds_id: str + name: str + stages: dict = field(default_factory=dict) + + def record(self, stage: str, ok: Optional[bool], note: str = "") -> None: + self.stages[stage] = ("PASS" if ok else "FAIL") if ok is not None else "SKIP" + if note: + self.stages[stage] += f" ({note})" + + @property + def failed(self) -> bool: + return any(v.startswith("FAIL") for v in self.stages.values()) + + +def log_failure(stage: str, ds_id: str, exc: Exception, hint: str) -> None: + """One ERROR line on the console with the attribution hint; the full + traceback goes to the log file at DEBUG.""" + log.error("[%s] stage %s FAILED: %s: %s — %s", + ds_id, stage, type(exc).__name__, exc, hint) + log.debug("traceback for %s/%s:\n%s", ds_id, stage, + "".join(traceback.format_exception(exc))) + + +def http_hint(exc: Exception) -> str: + """Attribute an HTTP-stage failure to node vs client.""" + if isinstance(exc, requests.exceptions.ConnectionError): + return "node unreachable (node side — is it running?)" + if isinstance(exc, requests.exceptions.HTTPError) and exc.response is not None: + code = exc.response.status_code + if code in (404, 405, 406): + return (f"HTTP {code}: node does not serve {SWE_PROTO} here " + "(node side — consys-proto module missing or format " + "not registered for this resource)") + if code >= 500: + return f"HTTP {code}: server exception (node side — check node logs)" + return f"HTTP {code} (likely node side)" + return "transport error" + + +# --------------------------------------------------------------------------- +# Cross-check helpers +# --------------------------------------------------------------------------- + + +def _parse_iso(ts: str) -> Optional[datetime]: + """Parse an ISO-8601 timestamp, tolerating the >6-digit fractional + seconds protobuf ``Timestamp.ToJsonString`` can emit (nanoseconds).""" + if not isinstance(ts, str): + return None + text = ts.replace("Z", "+00:00") + if "." in text: + head, _, tail = text.partition(".") + frac = tail[:-6] # strip the +00:00 suffix + text = f"{head}.{frac[:6].ljust(6, '0')}+00:00" + try: + return datetime.fromisoformat(text) + except ValueError: + return None + + +def _times_close(a: str, b: str, tol_s: float = 0.001) -> bool: + da, db = _parse_iso(a), _parse_iso(b) + if da is None or db is None: + return False + return abs((da - db).total_seconds()) <= tol_s + + +def values_match(proto_val: Any, json_val: Any) -> bool: + """Field-level equivalence between the proto and om+json wire forms. + + Numbers compare with a relative tolerance that absorbs float32 + round-tripping; timestamp strings compare to within 1 ms (om+json + carries millis, protobuf ``Timestamp`` carries nanos). + """ + if isinstance(proto_val, bool) or isinstance(json_val, bool): + return bool(proto_val) == bool(json_val) + if isinstance(proto_val, (int, float)) and isinstance(json_val, (int, float)): + return math.isclose(float(proto_val), float(json_val), + rel_tol=1e-6, abs_tol=1e-9) + if isinstance(proto_val, str) and isinstance(json_val, str): + return proto_val == json_val or _times_close(proto_val, json_val) + if isinstance(proto_val, dict) and isinstance(json_val, dict): + return all(k in json_val and values_match(v, json_val[k]) + for k, v in proto_val.items()) + if isinstance(proto_val, (list, tuple)) and isinstance(json_val, (list, tuple)): + return (len(proto_val) == len(json_val) + and all(values_match(p, j) for p, j in zip(proto_val, json_val))) + return proto_val == json_val + + +def cross_check(proto_obs: list[dict], json_obs: list[dict]) -> tuple[int, list[str]]: + """Compare decoded proto observations against the om+json reference. + + Observations are matched by ``phenomenonTime`` (the two HTTP fetches + race the live store, so the sets may only partially overlap). For each + matched pair, every result field present in *both* wire forms must + agree. The proto-only ``time`` result field (the SWE sampling-time + component, which om+json folds into ``phenomenonTime``) is checked + against the JSON ``phenomenonTime`` instead. + + :returns: ``(matched_count, mismatch_descriptions)`` + """ + json_by_time = {o["phenomenonTime"]: o for o in json_obs + if o.get("phenomenonTime")} + + def find_json_match(ptime: str) -> Optional[dict]: + if ptime in json_by_time: + return json_by_time[ptime] + for jt, obs in json_by_time.items(): # ns-vs-ms precision tolerance + if _times_close(ptime, jt): + return obs + return None + + matched = 0 + mismatches: list[str] = [] + for pobs in proto_obs: + ptime = pobs.get("phenomenonTime", "") + jobs = find_json_match(ptime) + if jobs is None: + continue + matched += 1 + presult, jresult = pobs.get("result", {}), jobs.get("result", {}) + for key, pval in presult.items(): + if key in jresult: + if not values_match(pval, jresult[key]): + mismatches.append( + f"@{ptime} result[{key!r}]: proto={pval!r} json={jresult[key]!r}") + elif key == "time" and isinstance(pval, str): + # om+json lifts the SWE time component out of `result`. + if not _times_close(pval, jobs.get("phenomenonTime", "")): + mismatches.append( + f"@{ptime} result['time']={pval!r} disagrees with " + f"phenomenonTime={jobs.get('phenomenonTime')!r}") + else: + mismatches.append( + f"@{ptime} result[{key!r}] present in proto but absent " + "from om+json — field-name drift?") + return matched, mismatches + + +# --------------------------------------------------------------------------- +# Stages +# --------------------------------------------------------------------------- + + +def fetch_proto_schema(api, ds_id: str) -> SWEProtobufDatastreamRecordSchema: + """Stages 1+2: fetch and parse the swe+proto schema document.""" + resp = api.get_resource( + APIResourceTypes.DATASTREAM, ds_id, APIResourceTypes.SCHEMA, + params={"obsFormat": SWE_PROTO}) + resp.raise_for_status() + return SWEProtobufDatastreamRecordSchema.from_sweproto_dict(resp.json()) + + +def fetch_observations_raw(api, ds_id: str, fmt: str) -> requests.Response: + """GET ``/datastreams/{id}/observations?f=&limit=N``.""" + resp = api.get_resource( + APIResourceTypes.DATASTREAM, ds_id, APIResourceTypes.OBSERVATION, + params={"f": fmt, "limit": OBS_COUNT}) + resp.raise_for_status() + return resp + + +def run_mqtt_stage(ds: Datastream, codec: SWEProtobufCodec, + report: DatastreamReport, + last_obs_time: Optional[str] = None) -> None: + """Stage 6: live MQTT subscription to the ``:data/swe-proto`` topic. + + Collects raw payloads on paho's network thread and decodes them here + afterwards, so a codec bug can't kill the network loop. + """ + if MQTT_SECS <= 0: + report.record("mqtt", None, "disabled via OSHC_PROTO_MQTT_SECS=0") + return + if ds._parent_node.get_mqtt_client() is None: + # MQTT_SECS > 0 (checked above) yet no client — the broker was + # unreachable at startup and main() fell back to HTTP-only. + report.record("mqtt", False, "MQTT broker unreachable") + return + + payloads: list[bytes] = [] + + def _on_msg(client, userdata, msg): + payloads.append(bytes(msg.payload)) + + try: + ds.set_connection_mode(StreamableModes.PULL) + ds.initialize() + topic = ds._topic + log.info("[%s] MQTT: subscribing to %s for up to %.0fs " + "(or %d messages)", report.ds_id, topic, MQTT_SECS, MQTT_MSGS) + ds._mqtt_client.subscribe(topic, msg_callback=_on_msg) + except Exception as exc: # noqa: BLE001 + log_failure("mqtt", report.ds_id, exc, + "subscription setup failed (client side — topic " + "construction or MQTT connection)") + report.record("mqtt", False, "subscribe failed") + return + + deadline = time.monotonic() + MQTT_SECS + while time.monotonic() < deadline and len(payloads) < MQTT_MSGS: + time.sleep(0.2) + + try: + ds._mqtt_client.unsubscribe(topic) + except Exception: # noqa: BLE001 + pass + + if not payloads: + # An idle producer is not a transport failure: if the latest stored + # observation predates the listen window by a wide margin, the + # stream simply isn't publishing right now (e.g. a static + # sensor-location output) and silence is the expected outcome. + last_dt = _parse_iso(last_obs_time) if last_obs_time else None + if last_dt is not None: + age = (datetime.now(last_dt.tzinfo) - last_dt).total_seconds() + if age > max(2 * MQTT_SECS, 30): + log.warning("[%s] MQTT: no messages in %.0fs, but the stream " + "is idle anyway (last stored observation %.0fs " + "ago) — cannot exercise the live path", + report.ds_id, MQTT_SECS, age) + report.record("mqtt", None, + f"stream idle (last obs {age:.0f}s ago)") + return + log.error("[%s] MQTT: no messages in %.0fs on %s while the stream " + "appears live — node side (is the consys-mqtt service " + "running and does it accept the swe-proto subtopic?)", + report.ds_id, MQTT_SECS, topic) + report.record("mqtt", False, "no messages received") + return + + decoded_count = 0 + for i, payload in enumerate(payloads): + log.debug("[%s] MQTT payload %d: %s", report.ds_id, i, hexdump(payload)) + json_doc = sniff_json(payload) + if json_doc is not None: + log.error( + "[%s] MQTT: node published JSON on the %s subtopic — the " + "format token was ignored (node side: the consys-mqtt " + "service did not apply %s content negotiation to the " + "outbound observation stream). Payload:\n%s", + report.ds_id, topic.rsplit(":", 1)[-1], SWE_PROTO, + json.dumps(json_doc, indent=2)[:800]) + report.record("mqtt", False, "node sent JSON, not protobuf") + return + try: + for obs in decode_payload(codec, payload): + decoded_count += 1 + if decoded_count <= 3: + log.info("[%s] MQTT observation %d:\n%s", report.ds_id, + decoded_count, json.dumps(obs, indent=2, default=str)) + except Exception as exc: # noqa: BLE001 + log_failure("mqtt", report.ds_id, exc, + "payload decode failed (contract drift — compare " + "hex dump in log file against the schema descriptor)") + report.record("mqtt", False, f"decode failed on message {i}") + return + + log.info("[%s] MQTT: decoded %d observation(s) from %d message(s)", + report.ds_id, decoded_count, len(payloads)) + report.record("mqtt", True, f"{decoded_count} obs") + + +def check_datastream(node: Node, ds: Datastream, + advertised: bool = True) -> DatastreamReport: + """Run all stages for one datastream and return its report.""" + api = node.get_api_helper() + res = ds.get_resource() + report = DatastreamReport(ds_id=res.ds_id, name=res.name or res.ds_id) + log.info("=== datastream %s (%s) ===", report.ds_id, report.name) + + # -- 0: format advertisement ---------------------------------------------- + if advertised: + report.record("advertised", True) + else: + log.error("[%s] %s missing from the datastream's `formats` list even " + "though the schema endpoint serves it — node side (the " + "consys-proto CustomObsFormat is not being reported on the " + "datastream resource)", report.ds_id, SWE_PROTO) + report.record("advertised", False, "format not in `formats` list") + + # -- 1+2: schema fetch + parse ------------------------------------------ + try: + schema = fetch_proto_schema(api, report.ds_id) + report.record("schema-fetch", True) + except (requests.exceptions.RequestException, ValueError) as exc: + log_failure("schema-fetch", report.ds_id, exc, http_hint(exc)) + report.record("schema-fetch", False) + for stage in STAGES[1:]: + report.record(stage, None, "blocked by schema-fetch") + return report + + try: + codec = SWEProtobufCodec(schema) + log.info("[%s] schema: message_type=%s result_fields=%s", + report.ds_id, schema.message_type, codec.result_field_names) + log.debug("[%s] rendered .proto source:\n%s", + report.ds_id, schema.to_proto_source()) + report.record("schema-parse", True) + except Exception as exc: # noqa: BLE001 + log_failure("schema-parse", report.ds_id, exc, + "descriptor unusable (contract drift — node's schema " + "document doesn't match the client's expectations)") + report.record("schema-parse", False) + for stage in STAGES[2:]: + report.record(stage, None, "blocked by schema-parse") + return report + + # Cache the proto schema on the datastream so init_mqtt() derives the + # :data/swe-proto subtopic (discovery prefers the swe+json schema). + ds._underlying_resource.record_schema = schema + + # -- 3: HTTP fetch ------------------------------------------------------- + try: + resp = fetch_observations_raw(api, report.ds_id, SWE_PROTO) + raw = resp.content + log.info("[%s] HTTP: %d bytes of %s", report.ds_id, len(raw), + resp.headers.get("Content-Type", "?")) + log.debug("[%s] HTTP body: %s", report.ds_id, hexdump(raw, 128)) + report.record("http-fetch", True) + except requests.exceptions.RequestException as exc: + log_failure("http-fetch", report.ds_id, exc, http_hint(exc)) + report.record("http-fetch", False) + for stage in ("decode", "cross-check"): + report.record(stage, None, "blocked by http-fetch") + run_mqtt_stage(ds, codec, report) + return report + + # -- 4: decode ----------------------------------------------------------- + proto_obs: list[dict] = [] + if not raw: + report.record("decode", None, "no stored observations") + report.record("cross-check", None, "no stored observations") + elif sniff_json(raw) is not None: + log.error("[%s] decode: HTTP body is JSON despite f=%s — node side " + "(content negotiation fell back to the default format)", + report.ds_id, SWE_PROTO) + report.record("decode", False, "node sent JSON, not protobuf") + report.record("cross-check", None, "blocked by decode") + else: + try: + frames = split_delimited(raw) + log.info("[%s] decode: %d delimited frame(s): %s bytes", + report.ds_id, len(frames), [len(f) for f in frames]) + for i, frame in enumerate(frames): + try: + proto_obs.append(codec.decode_with_envelope(frame)) + except Exception: # noqa: BLE001 + log.debug("[%s] frame %d hex: %s", report.ds_id, i, + hexdump(frame, 128)) + raise + for i, obs in enumerate(proto_obs[:3]): + log.info("[%s] HTTP observation %d:\n%s", report.ds_id, i + 1, + json.dumps(obs, indent=2, default=str)) + report.record("decode", True, f"{len(proto_obs)} obs") + except Exception as exc: # noqa: BLE001 + log_failure("decode", report.ds_id, exc, + "wire bytes don't match the delivered descriptor " + "(either side — hex dump in log file; diff against " + "the rendered .proto source)") + report.record("decode", False) + report.record("cross-check", None, "blocked by decode") + run_mqtt_stage(ds, codec, report) + return report + + # -- 5: cross-check against om+json ---------------------------------- + try: + json_resp = fetch_observations_raw(api, report.ds_id, + "application/om+json") + json_obs = json_resp.json().get("items", []) + matched, mismatches = cross_check(proto_obs, json_obs) + if mismatches: + for m in mismatches: + log.error("[%s] cross-check mismatch: %s", report.ds_id, m) + report.record("cross-check", False, + f"{len(mismatches)} mismatch(es)") + elif matched == 0: + log.warning("[%s] cross-check: no overlapping observations " + "between the proto and om+json fetches (store " + "churned between requests) — nothing compared", + report.ds_id) + report.record("cross-check", None, "no overlap") + else: + log.info("[%s] cross-check: %d observation(s) agree with " + "om+json", report.ds_id, matched) + report.record("cross-check", True, f"{matched} matched") + except Exception as exc: # noqa: BLE001 + log_failure("cross-check", report.ds_id, exc, + "om+json reference fetch/compare failed") + report.record("cross-check", False) + + # -- 6: MQTT -------------------------------------------------------------- + last_obs_time = max( + (o.get("phenomenonTime", "") for o in proto_obs), default=None) + run_mqtt_stage(ds, codec, report, last_obs_time) + return report + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def discover_proto_datastreams(node: Node) -> list[tuple[Datastream, bool]]: + """All discovered datastreams that serve ``application/swe+proto``. + + Returns ``(datastream, advertised)`` pairs. A datastream that lists + the format in its ``formats`` array is taken at its word; one that + doesn't is *probed* — a cheap schema GET — and still tested when the + node actually serves the format. The probe exists because a node + build was observed serving swe+proto on every endpoint while omitting + it from `formats`; refusing to test in that state would hide the one + finding that matters (the advertisement regression itself, reported + by the ``advertised`` stage). + """ + api = node.get_api_helper() + out: list[tuple[Datastream, bool]] = [] + for system in node._systems: + try: + system.discover_datastreams() + except Exception as exc: # noqa: BLE001 + log.error("datastream discovery failed for system %s: %s", + getattr(system, "_resource_id", "?"), exc) + log.debug("traceback:\n%s", traceback.format_exc()) + continue + for ds in system.datastreams: + formats = getattr(ds.get_resource(), "formats", None) or [] + if SWE_PROTO in formats: + out.append((ds, True)) + continue + try: + probe = api.get_resource( + APIResourceTypes.DATASTREAM, ds.get_id(), + APIResourceTypes.SCHEMA, params={"obsFormat": SWE_PROTO}) + if probe.ok: + log.warning("[%s] not advertised but the schema endpoint " + "serves %s — testing it anyway", + ds.get_id(), SWE_PROTO) + out.append((ds, False)) + else: + log.debug("[%s] schema probe: HTTP %s — not a swe+proto " + "datastream", ds.get_id(), probe.status_code) + except requests.exceptions.RequestException as exc: + log.debug("[%s] schema probe failed: %s", ds.get_id(), exc) + return out + + +def main() -> int: + setup_logging() + log.info("swe+proto node check — http://%s:%d (MQTT :%d)", + HOST, HTTP_PORT, MQTT_PORT) + log.info("log file: %s", LOG_PATH) + + osh = OSHConnect(name="swe-proto-check") + try: + node = Node(protocol="http", address=HOST, port=HTTP_PORT, + username=USER, password=PASS, + enable_mqtt=MQTT_SECS > 0, mqtt_port=MQTT_PORT) + except OSError as exc: + if MQTT_SECS <= 0: + raise + # A dead broker must not block the HTTP stages — fall back to an + # HTTP-only node; run_mqtt_stage attributes the missing client. + log.error("MQTT broker at %s:%d unreachable (%s) — continuing " + "HTTP-only (node side: MQTT service down or not started)", + HOST, MQTT_PORT, exc) + log.debug("traceback:\n%s", traceback.format_exc()) + node = Node(protocol="http", address=HOST, port=HTTP_PORT, + username=USER, password=PASS, enable_mqtt=False) + osh.add_node(node) + + try: + osh.discover_systems() + except requests.exceptions.RequestException as exc: + log_failure("discovery", "node", exc, http_hint(exc)) + return 2 + log.info("discovered %d system(s)", len(node._systems)) + + proto_streams = discover_proto_datastreams(node) + if not proto_streams: + log.error("no datastreams advertise or serve %s — node side (is the " + "consys-proto module installed and started?)", SWE_PROTO) + return 2 + log.info("found %d swe+proto datastream(s): %s", len(proto_streams), + [ds.get_id() for ds, _ in proto_streams]) + + reports = [check_datastream(node, ds, advertised) + for ds, advertised in proto_streams] + + # Clean MQTT shutdown so the broker sees a graceful close. + client = node.get_mqtt_client() + if client is not None: + try: + client.stop() + client.disconnect() + except Exception: # noqa: BLE001 + pass + + log.info("") + log.info("================ SUMMARY ================") + any_failed = False + for report in reports: + log.info("%s (%s):", report.ds_id, report.name) + for stage in STAGES: + log.info(" %-13s %s", stage, report.stages.get(stage, "SKIP")) + any_failed = any_failed or report.failed + log.info("==========================================") + log.info("result: %s — details in %s", + "FAIL" if any_failed else "PASS", LOG_PATH) + return 1 if any_failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/oshconnect/csapi4py/model_utils.py b/src/oshconnect/csapi4py/model_utils.py deleted file mode 100644 index 87cac03..0000000 --- a/src/oshconnect/csapi4py/model_utils.py +++ /dev/null @@ -1,10 +0,0 @@ -from pydantic import BaseModel - - -def serialize_model_list(model_list: list[BaseModel]): - """ - Serializes a list of pydantic models - :param model_list: - :return: a valid json string - """ - return '[' + ','.join([model.model_dump_json(exclude_none=True, by_alias=True) for model in model_list]) + ']' diff --git a/src/oshconnect/csapi4py/mqtt.py b/src/oshconnect/csapi4py/mqtt.py index 554d421..1b42e98 100644 --- a/src/oshconnect/csapi4py/mqtt.py +++ b/src/oshconnect/csapi4py/mqtt.py @@ -17,6 +17,7 @@ "application/swe+json": "swe-json", "application/swe+binary": "swe-binary", "application/swe+csv": "swe-csv", + "application/swe+proto": "swe-proto", "application/om+json": "om-json", "application/sml+json": "sml-json", } diff --git a/src/oshconnect/csapi4py/querymodel.py b/src/oshconnect/csapi4py/querymodel.py deleted file mode 100644 index f483b06..0000000 --- a/src/oshconnect/csapi4py/querymodel.py +++ /dev/null @@ -1,25 +0,0 @@ -from datetime import datetime -from typing import Union, Optional, List - -from pydantic import BaseModel, StrictStr, Field, field_validator - - -class QueryModel(BaseModel): - id: list = None - bbox: list = None - date_time: Union[StrictStr, datetime] = Field(None, alias='datetime') - geom: dict = None - q: list = Optional[List[str]] - parent: list = None - procedure: list = None - foi: list = None - observed_property: list = Field(None, serialization_alias='observedProperty') - controlled_property: list = Field(None, serialization_alias='controlledProperty') - recursive: bool = False - limit: int = Field(10, ge=1, le=10000) - - @field_validator('q') - def validate_q(cls, v): - if v is not None: - return v.split(',') - return v diff --git a/src/oshconnect/csapi4py/request_bodies.py b/src/oshconnect/csapi4py/request_bodies.py deleted file mode 100644 index 8291f15..0000000 --- a/src/oshconnect/csapi4py/request_bodies.py +++ /dev/null @@ -1,99 +0,0 @@ -from typing import Union - -from pydantic import BaseModel, HttpUrl, Field, model_serializer, RootModel, SerializeAsAny - -from .constants import DatastreamResultTypes -from oshconnect.datamodels.datastreams import DatastreamSchema -from oshconnect.datamodels.geometry import Geometry -from .sensor_ml.sml import TypeOf - - -# TODO: Consider some sort of Abstract Base Class for all valid request bodies to inherit from to reduce the complexity -# of the final request body. - -class GeoJSONBody(BaseModel): - type: str - id: str - properties: dict = None - geometry: Geometry = None - bbox: list = None - links: list = None - - -class SmlJSONBody(BaseModel): - object_type: str = Field(None, serialization_alias='type') - id: str = Field(None) - description: str = Field(None) - unique_id: str = Field(..., serialization_alias='uniqueId') - label: str = Field(...) - lang: str = None - keywords: list = None - identifiers: list = None - classifiers: list = None - valid_time: list = Field(None, serialization_alias='validTime') - security_constraints: list = Field(None, serialization_alias='securityConstraints') - legal_constraints: list = Field(None, serialization_alias='legalConstraints') - characteristics: list = None - capabilities: list = None - contacts: list = None - documents: list = None - history: list = None - definition: HttpUrl = None - type_of: TypeOf = Field(None, serialization_alias='typeOf') - configuration: HttpUrl = None - features_of_interest: list = Field(None, serialization_alias='featuresOfInterest') - inputs: list = None - outputs: list = None - parameters: list = None - modes: list = None - method: str = None - position: list = None - links: list = Field(None) - - -class OMJSONBody(BaseModel): - 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(None, alias="resultTime") - parameters: list = Field(None) - result: dict = Field(None) - result_links: list = Field(None, alias="result@links") - - -class DatastreamBodyJSON(BaseModel): - """ - NOTES: though the spec does not require that outputName, and schema be present, they are required for the - implementation of the API present on OSH - """ - id: str = Field(None) - name: str = Field(...) - description: str = Field(None) - deployment: HttpUrl = Field(None, serialization_alias='deployment@link') - ultimate_feature_of_interest: HttpUrl = Field(None, serialization_alias='featureOfInterest@link') - sampling_feature: HttpUrl = Field(None, serialization_alias='samplingFeature@link') - valid_time: list = Field(None, serialization_alias='validTime') - output_name: str = Field(..., serialization_alias='outputName') - phenomenon_time_interval: str = Field(None, serialization_alias='phenomenonTimeInterval') - result_time_interval: str = Field(None, serialization_alias='resultTimeInterval') - result_type: DatastreamResultTypes = Field(None, serialization_alias='resultType') - links: list = Field(None) - datastream_schema: SerializeAsAny[DatastreamSchema] = Field(..., serialization_alias='schema') - - -class RequestBody(BaseModel): - """ - Wrapper class to support different request json structures - """ - json_structure: Union[GeoJSONBody, SmlJSONBody, OMJSONBody, DatastreamSchema] = Field(..., - serialization_alias='json') - test_extra: str = Field("Hello, I am test", serialization_alias='testExtra') - - @model_serializer - def ser_model(self): - print("Serializing model...") - return self.json_structure - - -class RequestBodyList(RootModel): - root: list[Union[GeoJSONBody, SmlJSONBody, OMJSONBody, DatastreamSchema]] = Field(...) diff --git a/src/oshconnect/csapi4py/sml.py b/src/oshconnect/csapi4py/sml.py deleted file mode 100644 index 8c704b0..0000000 --- a/src/oshconnect/csapi4py/sml.py +++ /dev/null @@ -1,51 +0,0 @@ -from pydantic import BaseModel, HttpUrl, Field - - -class TypeOf(BaseModel): - """ - TypeOf is a resolvable reference to some other general process (that can be any type inheriting from AbstractProcess) - :param href: The URL of the referenced process - :param relationship: The relationship of the referenced process to the current process - :param media_type: The media type of the referenced process - :param href_lang: The language of the referenced process - :param title: The title of the referenced process - :param uid: The unique identifier of the referenced process - :param target_resource: The target resource of the referenced process - :param interface: The interface of the referenced process - """ - href: HttpUrl - relationship: str = Field(..., serialization_alias='rel') - media_type: str = Field(None, serialization_alias='type') - href_lang: str = Field(None, serialization_alias='hreflang') - title: str = Field(None) - uid: str = Field(None) - target_resource: str = Field(None, serialization_alias='rt') - interface: str = Field(None, serialization_alias='if') - - -class SMLAbstractProcess(BaseModel): - description: str = None - unique_id: str = Field(None, serialization_alias='uniqueID') - label: str = None - lang: str = None - keywords: list = None - identifiers: list = None - classifiers: list = None - valid_time: list = Field(None, serialization_alias='validTime') - security_constraints: list = Field(None, serialization_alias='securityConstraints') - legal_constraints: list = Field(None, serialization_alias='legalConstraints') - characteristics: list = None - capabilities: list = None - contacts: list = None - documents: list = None - history: list = None - definition: HttpUrl = None - type_of: TypeOf = Field(None, serialization_alias='typeOf') - configuration: HttpUrl = None - features_of_interest: list = Field(None, serialization_alias='featuresOfInterest') - inputs: list = None - outputs: list = None - parameters: list = None - modes: list = None - method: str = None - position: list = None diff --git a/src/oshconnect/encoding.py b/src/oshconnect/encoding.py index c8610c9..041d34f 100644 --- a/src/oshconnect/encoding.py +++ b/src/oshconnect/encoding.py @@ -110,14 +110,14 @@ 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``. + per-datastream Protobuf descriptor that the + `SWEProtobufDatastreamRecordSchema` carries (a serialized + ``google.protobuf.FileDescriptorSet``). 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. + a tag-length-value stream fully defined by the delivered descriptor. There is nothing to declare at the SDK level beyond "use the protobuf codec." """ diff --git a/src/oshconnect/network_properties.py b/src/oshconnect/network_properties.py deleted file mode 100644 index 51a0b95..0000000 --- a/src/oshconnect/network_properties.py +++ /dev/null @@ -1,10 +0,0 @@ -from pydantic import BaseModel, HttpUrl - - -class NetworkProperties(BaseModel): - endpoint_url: HttpUrl - tls: bool = False - stream_protocol: str = 'ws' - mqtt_opts: dict = None - mqtt_endpoint_url: HttpUrl = None - connector_opts: dict = None diff --git a/src/oshconnect/resources/datastream.py b/src/oshconnect/resources/datastream.py index 91524a9..b24255c 100644 --- a/src/oshconnect/resources/datastream.py +++ b/src/oshconnect/resources/datastream.py @@ -193,7 +193,15 @@ def _encode_for_wire(self, data) -> bytes: return SWEBinaryCodec(schema).encode(data) if isinstance(schema, SWEProtobufDatastreamRecordSchema): from ..swe_protobuf import SWEProtobufCodec # lazy: optional dep - return SWEProtobufCodec(schema).encode(data) + # `data` is the result record (fields 6+); the per-datastream + # message also carries envelope metadata (datastream_id + the + # observation timestamps) which the Datastream supplies from its + # own context rather than from the caller's result dict. + envelope = { + "datastream_id": self.get_id(), + "result_time": TimeInstant.now_as_time_instant(), + } + return SWEProtobufCodec(schema).encode(data, envelope=envelope) if isinstance(schema, SWEFlatBuffersDatastreamRecordSchema): from ..swe_flatbuffers import SWEFlatBuffersCodec # lazy: stub return SWEFlatBuffersCodec(schema).encode(data) diff --git a/src/oshconnect/resources/system.py b/src/oshconnect/resources/system.py index 9bf2d6f..fc76d53 100644 --- a/src/oshconnect/resources/system.py +++ b/src/oshconnect/resources/system.py @@ -207,7 +207,11 @@ def discover_datastreams(self) -> list[Datastream]: warnings.warn(msg, SchemaFetchWarning, stacklevel=2) datastreams.append(new_ds) - if not [ds.get_underlying_resource() != datastream_objs for ds in self.datastreams]: + # Dedup by server-side id so a re-discovery refreshes nothing + # but also duplicates nothing. (A previous expression here + # evaluated truthiness of a list comprehension, which only + # ever admitted the first datastream.) + if all(ds.get_id() != datastream_objs.ds_id for ds in self.datastreams): self.datastreams.append(new_ds) return datastreams @@ -259,7 +263,9 @@ def discover_controlstreams(self) -> list[ControlStream]: warnings.warn(msg, SchemaFetchWarning, stacklevel=2) controlstreams.append(new_cs) - if not [cs.get_underlying_resource() != controlstream_objs for cs in self.control_channels]: + # Same id-based dedup as discover_datastreams (the previous + # list-truthiness expression only ever admitted the first one). + if all(cs.get_id() != controlstream_objs.cs_id for cs in self.control_channels): self.control_channels.append(new_cs) return controlstreams diff --git a/src/oshconnect/schema_datamodels.py b/src/oshconnect/schema_datamodels.py index 2bab7ca..ceef8a5 100644 --- a/src/oshconnect/schema_datamodels.py +++ b/src/oshconnect/schema_datamodels.py @@ -6,10 +6,14 @@ # ============================================================================= from __future__ import annotations +import base64 from datetime import datetime from typing import Annotated, Union, List, Literal -from pydantic import BaseModel, Field, model_validator, HttpUrl, ConfigDict +from pydantic import ( + BaseModel, ConfigDict, Field, HttpUrl, field_serializer, field_validator, + model_validator, +) from .api_utils import Link, URI from .encoding import BinaryEncoding, FlatBuffersEncoding, JSONEncoding, ProtobufEncoding @@ -222,36 +226,60 @@ def from_swebinary_dict(cls, data: dict) -> "SWEBinaryDatastreamRecordSchema": 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). + ``application/swe+proto`` is the **per-datastream descriptor** Protobuf + encoding: each DataStream ships a pre-compiled Protobuf schema (a + serialized ``google.protobuf.FileDescriptorSet``) describing one + per-datastream observation message — envelope fields 1–5 (id, + datastream_id, foi_id, phenomenon/result time) plus the SWE Common + record at fields 6+. An observation on the wire is a serialized + instance of that message; receivers register the descriptor in a + ``DescriptorPool`` and decode dynamically (no ``protoc``). + + This carries the descriptor itself rather than a SWE ``recordSchema`` + tree — the descriptor is the source of truth for the wire layout, and + SWE semantics (uom, definition, …) travel as field options inside it. + The codec lives in ``oshconnect.swe_protobuf.SWEProtobufCodec``. + + .. note:: + + The JSON envelope that delivers the descriptor over the + ``/datastreams/{id}/schema`` endpoint is a "meet in the middle" + contract with the OSH node side — currently assumed to be + ``{"obsFormat", "messageType", "fileDescriptorSet": }``. + See ``docs/osh_spec_deviations.md`` (swe-proto-descriptor-format). """ 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. + # Serialized google.protobuf.FileDescriptorSet (carries the + # per-datastream message file plus its transitive imports). Stored as + # raw bytes in Python; (de)serialized as base64 in JSON so it survives + # the CS API schema document and the discriminated-union round-trip. + file_descriptor_set: bytes = Field(..., alias='fileDescriptorSet') + # Fully-qualified per-datastream message name (e.g. + # "georobotix.csapi....WeatherObservation"). Optional when the + # descriptor set carries exactly one message type. + message_type: str = Field(None, alias='messageType') + # Marker only — the wire layout is fully defined by the descriptor. 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 + @field_validator('file_descriptor_set', mode='before') + @classmethod + def _decode_base64_fds(cls, value): + """Accept the descriptor as base64 text (JSON wire form) or raw bytes.""" + if isinstance(value, str): + return base64.b64decode(value) + return value + + @field_serializer('file_descriptor_set', when_used='json') + def _encode_base64_fds(self, value: bytes) -> str: + return base64.b64encode(value).decode('ascii') def to_sweproto_dict(self) -> dict: - """Render as an `application/swe+proto` datastream-schema document.""" + """Render as an `application/swe+proto` datastream-schema document + (``fileDescriptorSet`` base64-encoded).""" return _dump_csapi(self) @classmethod @@ -259,6 +287,106 @@ 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) + @classmethod + def from_record_schema( + cls, + record: AnyComponent, + *, + message_name: str = "Observation", + package: str = "oshconnect.sweproto", + datatype_by_path: dict = None, + ) -> "SWEProtobufDatastreamRecordSchema": + """Generate a swe+proto schema from a SWE Common ``DataRecord``. + + Builds the per-datastream observation descriptor (envelope fields + 1–5 + the record's components as result fields 6+, nested records + and vectors recursed) — the inverse of OSH's ``ProtoSchemaWriter`` — + and wraps it in this schema model. Use this to produce a swe+proto + datastream schema from the ``record_schema`` you already hold for a + SWE+JSON or SWE+Binary datastream. + + :param record: a ``DataRecordSchema`` describing the observation. + :param message_name: generated message name (e.g. + ``f"Observation_{ds_id}"``). + :param package: proto package for the generated message. + :param datatype_by_path: optional ``{ref: dataType_uri}`` map (same + refs a ``BinaryEncoding`` uses) so float32 / int-width leaves + map to the matching proto wire type instead of the defaults. + """ + from .swe_protobuf import build_observation_descriptor_set + fds, message_type = build_observation_descriptor_set( + record, message_name=message_name, package=package, + datatype_by_path=datatype_by_path) + return cls(file_descriptor_set=fds, message_type=message_type) + + @classmethod + def from_other_schema( + cls, + other, + *, + message_name: str = "Observation", + package: str = "oshconnect.sweproto", + ) -> "SWEProtobufDatastreamRecordSchema": + """Translate another datastream schema into swe+proto. + + Accepts any record-bearing datastream schema (``SWEDatastreamRecordSchema``, + ``SWEBinaryDatastreamRecordSchema``, …) — pulling its + ``record_schema`` — or a bare ``DataRecordSchema``. When the source + is SWE+Binary, the ``recordEncoding`` members are mined for each + leaf's OGC ``dataType`` so the generated proto fields use the + matching wire type (e.g. float32 → ``float``); SWE+JSON / SWE+CSV / + SWE+text sources carry no dataType, so the node defaults apply. + """ + record = getattr(other, "record_schema", other) + datatype_by_path = None + record_encoding = getattr(other, "record_encoding", None) + members = getattr(record_encoding, "members", None) + if members: + datatype_by_path = { + m.ref: m.data_type + for m in members + if getattr(m, "type", None) == "Component" + and getattr(m, "ref", None) and getattr(m, "data_type", None) + } + return cls.from_record_schema( + record, message_name=message_name, package=package, + datatype_by_path=datatype_by_path) + + def to_proto_source(self) -> str: + """Render this schema's descriptor as editable ``.proto`` source text. + + Works for any swe+proto schema — one generated here *or* one + delivered by the node — since it renders the carried + ``FileDescriptorSet``. Edit the text and recompile it with + :meth:`from_proto_source` to apply changes. + """ + from .swe_protobuf import render_proto_source + return render_proto_source(self.file_descriptor_set, self.message_type) + + @classmethod + def from_proto_source( + cls, + proto_text: str, + *, + message_type: str = None, + protoc: str = "protoc", + ) -> "SWEProtobufDatastreamRecordSchema": + """Build a schema from ``.proto`` source text (compiles via ``protoc``). + + The round-trip companion to :meth:`to_proto_source`: generate the + text, hand-edit it (add/rename fields, tweak types, add + annotations), then compile it back into a schema. Requires + ``protoc`` on PATH (or pass ``protoc=``); the + binary-descriptor paths (`from_record_schema` / `from_other_schema`) + need no protoc. ``message_type`` defaults to the first message in + the compiled file. + """ + from .swe_protobuf import compile_proto_source, primary_message_type + fds = compile_proto_source(proto_text, protoc=protoc) + if message_type is None: + message_type = primary_message_type(fds) + return cls(file_descriptor_set=fds, message_type=message_type) + class SWEFlatBuffersDatastreamRecordSchema(DatastreamRecordSchema): """Datastream observation schema for ``application/swe+flatbuffers``. @@ -501,6 +629,7 @@ class SystemHistoryProperties(BaseModel): JSONCommandSchema.model_rebuild(force=True) SWEDatastreamRecordSchema.model_rebuild(force=True) SWEBinaryDatastreamRecordSchema.model_rebuild(force=True) -SWEProtobufDatastreamRecordSchema.model_rebuild(force=True) +# SWEProtobufDatastreamRecordSchema no longer threads `AnyComponent` (it +# carries the protobuf descriptor instead), so it needs no forced rebuild. SWEFlatBuffersDatastreamRecordSchema.model_rebuild(force=True) OMJSONDatastreamRecordSchema.model_rebuild(force=True) diff --git a/src/oshconnect/swe_protobuf.py b/src/oshconnect/swe_protobuf.py index fd09fd4..a88d9d5 100644 --- a/src/oshconnect/swe_protobuf.py +++ b/src/oshconnect/swe_protobuf.py @@ -1,6 +1,6 @@ # ============================================================================= # Copyright (c) 2026 Georobotix Innovative Research -# Date: 2026/5/19 +# Date: 2026/6/8 # Author: Ian Patterson # Contact Email: ian.patterson@georobotix.us # ============================================================================= @@ -9,626 +9,936 @@ 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. +``application/swe+proto`` is the **per-datastream descriptor** Protobuf +encoding. Each DataStream carries a pre-compiled Protobuf schema — a +``google.protobuf.FileDescriptorProto`` (delivered as a +``FileDescriptorSet`` so its imports travel with it) describing a single +per-datastream observation message of the shape: + +.. code-block:: protobuf + + message Observation_ { + // envelope (1–5) — observation metadata, not result data + string id = 1; + string datastream_id = 2; + string foi_id = 3; + google.protobuf.Timestamp phenomenon_time = 4; + google.protobuf.Timestamp result_time = 5; + // result data (6+) — the SWE Common DataRecord, one field per component + float air_temperature = 6; + float relative_humidity = 7; + ... + } + +An observation on the wire is a serialized instance of that message. +Receivers register the descriptor in a ``DescriptorPool`` and build the +message class dynamically — no ``protoc`` and no generated bindings. + +This replaces the earlier self-describing SWE Common 3 ``DataRecord`` +codec: that wire form (every value wrapped in a typed SWE submessage) is +**not** what ``application/swe+proto`` means anymore. See +``docs/osh_spec_deviations.md`` (swe-proto-descriptor-format). + +Result vs. envelope split +------------------------- +The fields **6+** are the SWE Common record — the same dict shape the +sibling ``application/swe+binary`` codec round-trips and the same thing +that lands in ``ObservationResource.result``. So :meth:`encode` / +:meth:`decode` operate on the **result record** keyed by field name; the +envelope fields (id / datastream_id / foi_id / the two timestamps) are +observation metadata supplied separately by the producing +``Datastream`` (on encode) and recoverable via +:meth:`decode_with_envelope` (on decode). The result dict is never +flattened together with the envelope, so a node that exposes both a +binary and a proto datastream yields the same ``result`` dict from +either. 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 +Only the ``protobuf`` runtime is required (install via the ``[protobuf]`` +extra). Unlike the previous codec, **no generated BinaryEncodings +modules are needed** — the per-datastream message is built dynamically +from the delivered descriptor. ``protobuf`` is imported lazily so +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 +import re +from typing import Any, Dict, List, Mapping, Optional, Tuple -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, -) +from .timemanagement import TimeInstant -# 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 +# Envelope fields (per-datastream message field numbers 1–5). These are +# observation metadata, not result data — kept out of the result record +# dict so the codec's value shape matches the swe+binary codec and +# ``ObservationResource.result``. +ENVELOPE_FIELD_NAMES: Tuple[str, ...] = ( + "id", "datastream_id", "foi_id", "phenomenon_time", "result_time", +) + +# Keys used by :meth:`decode_with_envelope` for the metadata block — the +# two timestamps use the CS API JSON spellings so callers can feed them +# straight into ``ObservationResource``/``ObservationOMJSONInline``. +_ENVELOPE_OUT_KEYS = { + "id": "id", + "datastream_id": "datastream@id", + "foi_id": "foi@id", + "phenomenon_time": "phenomenonTime", + "result_time": "resultTime", +} +_TIMESTAMP_FULL_NAME = "google.protobuf.Timestamp" _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\"" + "The 'protobuf' runtime is required for application/swe+proto. " + "Install it with:\n pip install 'oshconnect[protobuf]'" ) -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 +def _import_protobuf(): + """Import the protobuf runtime modules, with an install hint on failure.""" try: - import sweCommon3_pb2 as pb - import basic_types_pb2 as bt - import scalar_components_pb2 as sc - except ImportError as exc: + from google.protobuf import ( # noqa: F401 + descriptor_pb2, descriptor_pool, message_factory, + ) + except ImportError as exc: # pragma: no cover - exercised via install hint 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 - + return descriptor_pb2, descriptor_pool, message_factory -# --------------------------------------------------------------------------- -# 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 wrap_file_descriptor_proto(fdp_bytes: bytes) -> bytes: + """Wrap a serialized ``FileDescriptorProto`` in a one-file + ``FileDescriptorSet``. -def _encode_boolean(_schema: BooleanSchema, value: Any): - msg = _sc.Boolean() - msg.value = bool(value) - return msg + Convenience for callers holding a bare ``FileDescriptorProto`` (e.g. + a single ``.proto`` with no non-google imports). A descriptor with + non-google dependencies must instead ship a full ``FileDescriptorSet`` + that carries them — otherwise the import won't resolve. + """ + descriptor_pb2, _, _ = _import_protobuf() + fdp = descriptor_pb2.FileDescriptorProto() + fdp.ParseFromString(fdp_bytes) + fds = descriptor_pb2.FileDescriptorSet() + fds.file.append(fdp) + return fds.SerializeToString() + + +def _coerce_descriptor_set(raw: bytes): + """Parse ``raw`` into a non-empty ``FileDescriptorSet``. + + The delivery contract is a serialized ``FileDescriptorSet`` (it can + carry transitive dependencies). A bare ``FileDescriptorProto`` is + *not* auto-detected — at the wire level it's ambiguous with a Set, so + decoding one as the other yields silent garbage. Wrap a single + descriptor with :func:`wrap_file_descriptor_proto` first. + """ + descriptor_pb2, _, _ = _import_protobuf() + fds = descriptor_pb2.FileDescriptorSet() + fds.ParseFromString(raw) + if not fds.file: + raise ValueError( + "application/swe+proto: expected a serialized FileDescriptorSet " + "but parsed zero files. If you have a bare FileDescriptorProto, " + "wrap it with oshconnect.swe_protobuf.wrap_file_descriptor_proto().") + return fds -def _decode_boolean(msg) -> bool: - return bool(msg.value) +def _build_message_class(fds_bytes: bytes, message_type: Optional[str]): + """Build a dynamic message class from a serialized ``FileDescriptorSet``. + Seeds any ``google/protobuf/*`` imports from the default pool (so + well-known types like ``Timestamp`` resolve without the caller + shipping them), adds the provided files in dependency order, then + resolves ``message_type`` (or the sole message if the set has exactly + one and no name was given). -def _encode_count(_schema: CountSchema, value: Any): - msg = _sc.Count() - msg.value = int(value) - return msg + :raises ImportError: if a non-google dependency is missing from the set. + :raises KeyError: if ``message_type`` is absent / ambiguous. + """ + import importlib + descriptor_pb2, descriptor_pool, message_factory = _import_protobuf() + fds = _coerce_descriptor_set(fds_bytes) + pool = descriptor_pool.DescriptorPool() -def _decode_count(msg) -> int: - return int(msg.value) + available: set = set() + provided = {f.name: f for f in fds.file} + def seed_google(dep: str) -> None: + if dep in available or dep in provided: + return + if not dep.startswith("google/protobuf/"): + return + # Well-known types load lazily — importing their generated module + # registers the file and gives us its descriptor to copy into our + # private pool (the default pool's FindFileByName 404s until then). + stem = dep.rsplit("/", 1)[-1][:-len(".proto")] + mod = importlib.import_module(f"google.protobuf.{stem}_pb2") + proto = descriptor_pb2.FileDescriptorProto() + mod.DESCRIPTOR.CopyToProto(proto) + for sub in proto.dependency: + seed_google(sub) + try: + pool.Add(proto) + except TypeError: # pragma: no cover - already present + pass + available.add(dep) + + for f in fds.file: + for dep in f.dependency: + seed_google(dep) + + # Topologically add the provided files: only add a file once all of + # its dependencies are already in the pool. Tolerant of any ordering + # in the delivered set. + remaining = list(fds.file) + progressed = True + while remaining and progressed: + progressed = False + for f in list(remaining): + if all(dep in available for dep in f.dependency): + pool.Add(f) + available.add(f.name) + remaining.remove(f) + progressed = True + if remaining: + missing = sorted({ + dep for f in remaining for dep in f.dependency if dep not in available + }) + raise ImportError( + "application/swe+proto: descriptor set is missing dependencies " + f"{missing}. Deliver a FileDescriptorSet that includes all " + "transitive imports (e.g. protoc --include_imports " + "--descriptor_set_out).") + + if not message_type: + message_names = [ + f"{f.package + '.' if f.package else ''}{m.name}" + for f in fds.file for m in f.message_type + ] + if len(message_names) != 1: + raise KeyError( + "application/swe+proto: descriptor set carries " + f"{len(message_names)} message types {message_names}; a " + "message_type must be specified to disambiguate.") + message_type = message_names[0] -def _encode_quantity(_schema: QuantitySchema, value: Any): - msg = _sc.Quantity() - msg.value.number = float(value) - return msg + descriptor = pool.FindMessageTypeByName(message_type) + return descriptor, message_factory.GetMessageClass(descriptor) -def _decode_quantity(msg) -> Union[float, str]: - """Decode a `Quantity` value. +def _set_timestamp(ts_field, value: Any) -> None: + """Populate a ``google.protobuf.Timestamp`` submessage from a Python time. - 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. + Accepts an ISO 8601 string, epoch seconds (int/float), a ``datetime``, + or a `TimeInstant`. """ - 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, TimeInstant): + value = value.get_iso_time() if isinstance(value, str): - msg.value.date_time = value + ts_field.FromJsonString(value) + elif isinstance(value, bool): + raise TypeError("Timestamp value cannot be a bool.") elif isinstance(value, (int, float)): - msg.value.number = float(value) + secs = int(value) + ts_field.seconds = secs + ts_field.nanos = int(round((value - secs) * 1_000_000_000)) + elif hasattr(value, "year") and hasattr(value, "month"): # datetime-like + ts_field.FromDatetime(value) else: raise TypeError( - f"Time value must be ISO 8601 string or numeric epoch seconds, " - f"got {type(value).__name__}") - return msg + "Timestamp value must be an ISO 8601 string, epoch seconds, " + f"datetime, or TimeInstant; got {type(value).__name__}.") -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 _is_timestamp(field_descriptor) -> bool: + msg_type = field_descriptor.message_type + return msg_type is not None and msg_type.full_name == _TIMESTAMP_FULL_NAME -def _encode_category(_schema: CategorySchema, value: Any): - msg = _sc.Category() - msg.value = str(value) - return msg +# --------------------------------------------------------------------------- +# Schema generation — translate a SWE Common record into a per-datastream +# observation descriptor (the inverse of OSH's ProtoSchemaWriter). +# --------------------------------------------------------------------------- -def _decode_category(msg) -> str: - return msg.value +def _proto_field_name(name: str) -> str: + """Sanitize a SWE component name into a valid proto3 field identifier. + Non-identifier characters become ``_``; a leading digit is prefixed + with ``_``. Names that are already valid identifiers (the common case + — ``temp``, ``samples``, ``clear_sky``) pass through unchanged. + """ + if not name: + raise ValueError("Cannot generate a proto field from an unnamed component.") + sanitized = re.sub(r"[^0-9A-Za-z_]", "_", name) + if sanitized[0].isdigit(): + sanitized = "_" + sanitized + return sanitized + + +_OGC_DATATYPE_BASE = "http://www.opengis.net/def/dataType/OGC/0/" + +# OGC SWE dataType URI → proto field type, mirroring the node's +# ProtoSchemaWriter.getDataType (FLOAT→float, DOUBLE→double, signed/unsigned +# int widths, signedByte→sint32). The descriptor must declare the same wire +# type the producer encodes, or the bytes won't interoperate — so a float32 +# quantity becomes ``float``, not ``double``. +_DATATYPE_URI_TO_PROTO: Dict[str, int] = {} + + +def _datatype_uri_to_proto(uri: str) -> int: + descriptor_pb2, _, _ = _import_protobuf() + if not _DATATYPE_URI_TO_PROTO: + T = descriptor_pb2.FieldDescriptorProto + b = _OGC_DATATYPE_BASE + _DATATYPE_URI_TO_PROTO.update({ + b + "double": T.TYPE_DOUBLE, + b + "float64": T.TYPE_DOUBLE, + b + "float32": T.TYPE_FLOAT, + b + "signedByte": T.TYPE_SINT32, + b + "unsignedByte": T.TYPE_UINT32, + b + "signedShort": T.TYPE_INT32, + b + "unsignedShort": T.TYPE_UINT32, + b + "signedInt": T.TYPE_INT32, + b + "unsignedInt": T.TYPE_UINT32, + b + "signedLong": T.TYPE_INT64, + b + "unsignedLong": T.TYPE_UINT64, + }) + if uri not in _DATATYPE_URI_TO_PROTO: + raise NotImplementedError( + f"swe+proto schema generation: dataType {uri!r} has no proto " + f"mapping. Known: {sorted(_DATATYPE_URI_TO_PROTO)}") + return _DATATYPE_URI_TO_PROTO[uri] -def _encode_text(_schema: TextSchema, value: Any): - msg = _sc.Text() - msg.value = str(value) - return msg +def _is_iso_time(component) -> bool: + """A Time is ISO (→ Timestamp) when its uom is the ISO-8601 calendar + reference; otherwise it's a numeric epoch (→ double), matching the + node's ``Time.isIsoTime()``.""" + uom = getattr(component, "uom", None) + href = getattr(uom, "href", None) if uom is not None else None + return bool(href and "ISO-8601" in str(href)) -def _decode_text(msg) -> str: - return msg.value +_PROTO_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") -# --------------------------------------------------------------------------- -# Composite encoders / decoders. Recurse via `_dispatch_table`. -# --------------------------------------------------------------------------- +def _is_proto_ident(name: str) -> bool: + return bool(_PROTO_IDENT_RE.match(name)) -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 _allowed_tokens(component) -> Optional[List[str]]: + """Return a Category's ``AllowedTokens`` value list, or ``None``. -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). + A ``Category`` constrained to a fixed token set becomes a proto enum + (matching the node's ``writeEnum``); an unconstrained one stays a + string. The constraint is loosely typed (``Any``) — accept either an + ``AllowedTokens``-shaped dict (``{"values": [...]}``) or an object + exposing ``values``. A constraint without a non-empty value list + (e.g. pattern-only) returns ``None`` and the component stays a string. """ - table = _dispatch_table() - oneof = any_component.WhichOneof("component") - if oneof is None: + from .swe_components import CategorySchema + if not isinstance(component, CategorySchema): 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`. + constraint = getattr(component, "constraint", None) + if not constraint: + return None + if isinstance(constraint, Mapping): + values = constraint.get("values") + else: + values = getattr(constraint, "values", None) + if isinstance(values, (list, tuple)) and len(values) > 0: + return [str(v) for v in values] + return None + + +def _scalar_proto_type(component, data_type: Optional[str] = None) -> Tuple[int, Optional[str]]: + """Map a SWE scalar component to a ``(proto field type, type_name)`` pair. + + Faithful to the node's ``ProtoSchemaWriter.getDataType``: the numeric + wire type follows the component's OGC ``dataType`` when one is known + (``data_type`` arg, or a ``data_type``/``dataType`` attribute on the + component) — so float32 → ``float``, signedLong → ``int64``, etc. When + no dataType is known the node *defaults* apply (Quantity → double, + Count → int32), which match the node's own defaults. Time → ``Timestamp`` + when ISO (wire-identical to the node's ``Time{seconds,nanos}``) else + ``double``; Category/Text → string; Boolean → bool. + + Nested records and vectors are handled by the caller + (``build_observation_descriptor_set``) before reaching here, so this + only sees leaf components. + + :raises NotImplementedError: for components with no scalar mapping — + DataChoice, ranges, geometry, matrices — or an unmapped dataType. """ - 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 - + descriptor_pb2, _, _ = _import_protobuf() + from .swe_components import ( + BooleanSchema, CategorySchema, CountSchema, QuantitySchema, + TextSchema, TimeSchema, + ) + T = descriptor_pb2.FieldDescriptorProto + dt = data_type or getattr(component, "data_type", None) or getattr(component, "dataType", None) + + if isinstance(component, BooleanSchema): + return T.TYPE_BOOL, None + if isinstance(component, (QuantitySchema, CountSchema)): + if dt: + return _datatype_uri_to_proto(dt), None + # No declared dataType → node defaults (Quantity DOUBLE, Count INT). + return (T.TYPE_DOUBLE if isinstance(component, QuantitySchema) + else T.TYPE_INT32), None + if isinstance(component, TimeSchema): + if dt: + return _datatype_uri_to_proto(dt), None + if _is_iso_time(component): + return T.TYPE_MESSAGE, ".google.protobuf.Timestamp" + return T.TYPE_DOUBLE, None + if isinstance(component, (CategorySchema, TextSchema)): + # A constraint-free Category/Text maps to string (matching the node). + # A Category *with* an AllowedTokens constraint is handled earlier in + # build_observation_descriptor_set (emitted as a proto enum), so it + # never reaches here. + return T.TYPE_STRING, None + raise NotImplementedError( + f"swe+proto schema generation: component " + f"{type(component).__name__} ({getattr(component, 'name', '?')!r}) is " + "not a supported scalar. DataArray/DataChoice, ranges, geometry, " + "and matrices are not yet translatable to a per-datastream " + "observation message.") + + +def build_observation_descriptor_set( + record, + *, + message_name: str = "Observation", + package: str = "oshconnect.sweproto", + datatype_by_path: Optional[Mapping[str, str]] = None, +) -> Tuple[bytes, str]: + """Build a per-datastream swe+proto observation descriptor from a SWE record. + + Produces the inverse of OSH's ``ProtoSchemaWriter``: a serialized + ``FileDescriptorSet`` for a message with the fixed envelope at fields + 1–5 (``id``, ``datastream_id``, ``foi_id``, ``phenomenon_time``, + ``result_time``) and the record's components mapped to result fields + 6+ in declaration order. Nested records and vectors become nested + ``Rec`` / ``Vec`` message types (inner fields numbered from 1), + recursed to arbitrary depth. + + :param record: a SWE ``DataRecordSchema`` (the ``record_schema`` that + the SWE+JSON / SWE+Binary datastream schemas carry). + :param message_name: the generated message name (e.g. + ``"Observation_"``). + :param package: the proto package for the generated message. + :param datatype_by_path: optional ``{json_pointer_ref: dataType_uri}`` + map giving the OGC dataType of leaf components by their record path + (e.g. ``{"/temp": ".../float32", "/pos/x": ".../float32"}``) — the + same refs a SWE ``BinaryEncoding`` uses. Lets float32 / int-width + components map to the matching proto wire type instead of the + defaults. ``SWEProtobufDatastreamRecordSchema.from_other_schema`` + builds this automatically from a SWE+Binary source. + :returns: ``(file_descriptor_set_bytes, fully_qualified_message_type)``. + :raises TypeError: if ``record`` is not a ``DataRecordSchema``. + :raises NotImplementedError: for components not yet translatable + (DataChoice, ranges, geometry, matrices). + """ + descriptor_pb2, _, _ = _import_protobuf() + from .swe_components import DataArraySchema, DataRecordSchema, VectorSchema + datatypes = dict(datatype_by_path or {}) -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: + if not isinstance(record, DataRecordSchema): 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 + "swe+proto schema generation requires a DataRecordSchema root; " + f"got {type(record).__name__}. Wrap scalars/vectors in a " + "DataRecord first.") + + T = descriptor_pb2.FieldDescriptorProto + + fdp = descriptor_pb2.FileDescriptorProto() + fdp.name = f"{package.replace('.', '/')}/{message_name}.proto" + fdp.package = package + fdp.syntax = "proto3" + fdp.dependency.append("google/protobuf/timestamp.proto") + + # Nested records / vectors become their own message types in the file, + # referenced by a message-typed field. Names are cosmetic (the wire is + # field-number driven), but we mirror the node's `Rec` / `Vec` + # convention; a counter guarantees uniqueness across nesting levels. + nested_counter = [0] + + def add_field(msg_proto, name, number, ftype, type_name=None, repeated=False): + f = msg_proto.field.add() + f.name = name + f.number = number + f.label = T.LABEL_REPEATED if repeated else T.LABEL_OPTIONAL + f.type = ftype + if type_name is not None: + f.type_name = type_name + + def add_component_field(msg_proto, component, number, path, repeated=False): + """Add one field for ``component`` to ``msg_proto``, creating any + nested message / enum types it needs. ``repeated`` is set for + DataArray elements.""" + fname = _proto_field_name(component.name) + if isinstance(component, DataRecordSchema): + nested = make_nested("Rec", component.fields, path) + add_field(msg_proto, fname, number, T.TYPE_MESSAGE, + f".{package}.{nested}", repeated) + elif isinstance(component, VectorSchema): + nested = make_nested("Vec", component.coordinates, path) + add_field(msg_proto, fname, number, T.TYPE_MESSAGE, + f".{package}.{nested}", repeated) + elif isinstance(component, DataArraySchema): + nested = make_array(component.element_type, path) + add_field(msg_proto, fname, number, T.TYPE_MESSAGE, + f".{package}.{nested}", repeated) + elif _allowed_tokens(component): + # Constrained Category → proto enum (matching node writeEnum). + enum_name = make_enum(msg_proto, fname, _allowed_tokens(component)) + add_field(msg_proto, fname, number, T.TYPE_ENUM, + f".{package}.{msg_proto.name}.{enum_name}", repeated) + else: + ftype, type_name = _scalar_proto_type( + component, data_type=datatypes.get(path)) + add_field(msg_proto, fname, number, ftype, type_name, repeated) + + def populate(msg_proto, components, start_number, used, path): + number = start_number + for component in components: + fname = _proto_field_name(component.name) + if fname in used: + raise ValueError( + f"swe+proto schema generation: component name " + f"{component.name!r} sanitizes to {fname!r}, which " + "collides with a sibling field. Rename to avoid the clash.") + add_component_field(msg_proto, component, number, + f"{path}/{component.name}") + used.add(fname) + number += 1 + + def make_nested(prefix, components, path): + """Create a nested message type (inner fields numbered from 1) and + return its name.""" + nested_counter[0] += 1 + nested_msg = fdp.message_type.add() + nested_msg.name = f"{prefix}{nested_counter[0]}" + populate(nested_msg, components, 1, set(), path) + return nested_msg.name + + def make_array(element_type, path): + """Create an ``Array`` wrapper message holding one repeated field + for the element (matching the node's ``writeArraySchema``) and return + its name. The element may itself be a scalar, record, vector, or + constrained category.""" + nested_counter[0] += 1 + array_msg = fdp.message_type.add() + array_msg.name = f"Array{nested_counter[0]}" + add_component_field(array_msg, element_type, 1, + f"{path}/{element_type.name}", repeated=True) + return array_msg.name + + def make_enum(msg_proto, field_name, tokens): + """Create an enum type (nested in the containing message, values + numbered from 0 — matching the node's ``writeEnum``) and return its + name. Tokens are used verbatim as enum value identifiers, as the + node does; a token that isn't a valid identifier is an error.""" + enum_proto = msg_proto.enum_type.add() + enum_name = f"Enum_{field_name}" + enum_proto.name = enum_name + for i, token in enumerate(tokens): + if not _is_proto_ident(token): + raise ValueError( + f"swe+proto schema generation: Category token {token!r} is " + "not a valid proto enum identifier " + "([A-Za-z_][A-Za-z0-9_]*); the node requires enumerable " + "tokens to be valid identifiers.") + value = enum_proto.value.add() + value.name = token + value.number = i + return enum_name + + top = fdp.message_type.add() + top.name = message_name + add_field(top, "id", 1, T.TYPE_STRING) + add_field(top, "datastream_id", 2, T.TYPE_STRING) + add_field(top, "foi_id", 3, T.TYPE_STRING) + add_field(top, "phenomenon_time", 4, T.TYPE_MESSAGE, ".google.protobuf.Timestamp") + add_field(top, "result_time", 5, T.TYPE_MESSAGE, ".google.protobuf.Timestamp") + populate(top, record.fields, 6, set(ENVELOPE_FIELD_NAMES), "") + + fds = descriptor_pb2.FileDescriptorSet() + fds.file.append(fdp) + return fds.SerializeToString(), f"{package}.{message_name}" + + +# --------------------------------------------------------------------------- +# .proto source rendering — turn a descriptor into editable text. The text +# is a faithful rendering of the descriptor (not a second generator), so the +# two never drift; edit it and recompile with `from_proto_source`. +# --------------------------------------------------------------------------- + + +def _proto_type_keyword(field_type) -> Optional[str]: + descriptor_pb2, _, _ = _import_protobuf() + T = descriptor_pb2.FieldDescriptorProto return { - "bigEndian": enc.ByteOrder.BYTE_ORDER_BIG_ENDIAN, - "littleEndian": enc.ByteOrder.BYTE_ORDER_LITTLE_ENDIAN, - }[byte_order] + T.TYPE_DOUBLE: "double", T.TYPE_FLOAT: "float", + T.TYPE_INT64: "int64", T.TYPE_UINT64: "uint64", + T.TYPE_INT32: "int32", T.TYPE_UINT32: "uint32", + T.TYPE_FIXED64: "fixed64", T.TYPE_FIXED32: "fixed32", + T.TYPE_SFIXED64: "sfixed64", T.TYPE_SFIXED32: "sfixed32", + T.TYPE_SINT64: "sint64", T.TYPE_SINT32: "sint32", + T.TYPE_BOOL: "bool", T.TYPE_STRING: "string", T.TYPE_BYTES: "bytes", + }.get(field_type) + + +def _render_enum(enum_proto, indent: int) -> List[str]: + pad = " " * indent + out = [f"{pad}enum {enum_proto.name} {{"] + for value in enum_proto.value: + out.append(f"{pad} {value.name} = {value.number};") + out.append(f"{pad}}}") + return out -def _encode_data_array(schema: DataArraySchema, value: Any): - """Build a protobuf `DataArray` from a list of element values. +def _render_message(msg_proto, indent: int) -> List[str]: + descriptor_pb2, _, _ = _import_protobuf() + T = descriptor_pb2.FieldDescriptorProto + pad = " " * indent + out = [f"{pad}message {msg_proto.name} {{"] + for enum_proto in msg_proto.enum_type: + out.extend(_render_enum(enum_proto, indent + 1)) + for nested in msg_proto.nested_type: + out.extend(_render_message(nested, indent + 1)) + fpad = " " * (indent + 1) + for field in msg_proto.field: + label = "repeated " if field.label == T.LABEL_REPEATED else "" + if field.type in (T.TYPE_MESSAGE, T.TYPE_ENUM): + type_str = field.type_name # fully-qualified, leading-dot form + else: + type_str = _proto_type_keyword(field.type) + out.append(f"{fpad}{label}{type_str} {field.name} = {field.number};") + out.append(f"{pad}}}") + return out - 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()`. +def render_proto_source(file_descriptor_set: bytes, + message_type: Optional[str] = None) -> str: + """Render a ``FileDescriptorSet`` to ``.proto`` source text. + + Renders the file that defines ``message_type`` (or the first + non-google file if not given) — the per-datastream schema, not its + imported well-known types. The output is editable and recompilable + with :meth:`SWEProtobufDatastreamRecordSchema.from_proto_source`. """ - 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. + fds = _coerce_descriptor_set(file_descriptor_set) + target = None + if message_type: + pkg, _, name = message_type.rpartition(".") + for f in fds.file: + if f.package == pkg and any(m.name == name for m in f.message_type): + target = f + break + if target is None: + target = next((f for f in fds.file + if not f.name.startswith("google/protobuf/")), None) + if target is None: + raise ValueError("render_proto_source: no renderable file in the set.") + + lines = ['syntax = "proto3";', ""] + if target.package: + lines += [f"package {target.package};", ""] + for dep in target.dependency: + lines.append(f'import "{dep}";') + if target.dependency: + lines.append("") + for msg_proto in target.message_type: + lines += _render_message(msg_proto, 0) + lines.append("") + return "\n".join(lines).rstrip("\n") + "\n" + + +def compile_proto_source(proto_text: str, *, protoc: str = "protoc") -> bytes: + """Compile ``.proto`` source text into a serialized ``FileDescriptorSet``. + + Shells out to ``protoc`` (required — raises if it isn't on PATH). The + well-known type imports (``google/protobuf/*``) resolve from protoc's + bundled includes; the codec seeds them, so they are not embedded. """ - 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) + import os + import shutil + import subprocess + import tempfile + + if shutil.which(protoc) is None and not os.path.isfile(protoc): + raise RuntimeError( + f"protoc not found ({protoc!r}). Install the Protocol Buffers " + "compiler (or pass protoc=) to compile .proto source. The " + "binary-descriptor path (from_record_schema) needs no protoc.") + with tempfile.TemporaryDirectory() as work: + proto_path = os.path.join(work, "schema.proto") + out_path = os.path.join(work, "schema.fds") + with open(proto_path, "w", encoding="utf-8") as fh: + fh.write(proto_text) + result = subprocess.run( + [protoc, f"--proto_path={work}", + f"--descriptor_set_out={out_path}", proto_path], + capture_output=True, text=True) + if result.returncode != 0: + raise ValueError( + f"protoc failed to compile the .proto source:\n{result.stderr}") + with open(out_path, "rb") as fh: + return fh.read() -# --------------------------------------------------------------------------- -# Public codec class -# --------------------------------------------------------------------------- +def primary_message_type(file_descriptor_set: bytes) -> Optional[str]: + """Fully-qualified name of the first message in the first non-google file + — the per-datastream observation message (the root).""" + fds = _coerce_descriptor_set(file_descriptor_set) + for f in fds.file: + if f.name.startswith("google/protobuf/") or not f.message_type: + continue + name = f.message_type[0].name + return f"{f.package}.{name}" if f.package else name + return None 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. + """Descriptor-driven encoder/decoder for ``application/swe+proto``. + + Construct from a parsed ``SWEProtobufDatastreamRecordSchema`` (which + carries the serialized ``FileDescriptorSet`` and the fully-qualified + per-datastream message type), or directly from + ``file_descriptor_set`` bytes + ``message_type``. + + :meth:`encode` / :meth:`decode` operate on the **result record** — a + ``{field_name: value}`` mapping over the per-datastream message's + result fields (numbers 6+). The envelope fields (``id``, + ``datastream_id``, ``foi_id``, ``phenomenon_time``, ``result_time``) + are observation metadata: pass them to :meth:`encode` via + ``envelope=`` and read them back with :meth:`decode_with_envelope`. + + Supported result-field types: protobuf scalars (numbers, bool, + string, bytes), ``google.protobuf.Timestamp`` (decoded to an ISO 8601 + string), **enums** (a constrained ``Category`` — encode accepts the + token string, decode returns it), **nested messages** (nested records + and vectors recurse into nested dicts; a vector may be given as a + sequence on encode and comes back as a dict keyed by coordinate name), + and **repeated** fields. A ``DataArray`` is the node's + ``Array { repeated = 1 }`` wrapper, so it round-trips as + ``{array_name: {element_name: [...]}}``; the element may itself be a + scalar, record, vector, or constrained category. """ def __init__( self, - schema: Union[SWEProtobufDatastreamRecordSchema, AnyComponentSchema], + schema: Any = None, + *, + file_descriptor_set: Optional[bytes] = None, + message_type: Optional[str] = None, ): - _load_pb_modules() - if isinstance(schema, SWEProtobufDatastreamRecordSchema): - self._root_schema = schema.record_schema - elif isinstance(schema, AnyComponentSchema): - self._root_schema = schema + if schema is not None: + file_descriptor_set = getattr(schema, "file_descriptor_set", None) + message_type = getattr(schema, "message_type", None) + if not file_descriptor_set: + raise ValueError( + "SWEProtobufCodec requires a FileDescriptorSet — pass a " + "SWEProtobufDatastreamRecordSchema or file_descriptor_set bytes.") + self._descriptor, self._message_cls = _build_message_class( + file_descriptor_set, message_type) + # Result fields = everything that isn't an envelope field, in + # field-number order (descriptor.fields is number-ordered). + self._result_fields = [ + f for f in self._descriptor.fields + if f.name not in ENVELOPE_FIELD_NAMES + ] + self._fields_by_name = {f.name: f for f in self._descriptor.fields} + + @property + def result_field_names(self) -> List[str]: + """Names of the per-datastream message's result fields (6+).""" + return [f.name for f in self._result_fields] + + # -- encode ------------------------------------------------------------ + + def encode(self, record: Mapping[str, Any], *, + envelope: Mapping[str, Any] = None) -> bytes: + """Encode one observation's result record into wire bytes. + + :param record: ``{field_name: value}`` over the result fields + (6+). Keys that name envelope fields are ignored here — pass + those via ``envelope`` instead. + :param envelope: optional ``{field_name: value}`` for the + envelope fields (``id``, ``datastream_id``, ``foi_id``, + ``phenomenon_time``, ``result_time``). Timestamp fields accept + ISO strings, epoch seconds, ``datetime``, or `TimeInstant`. + :raises KeyError: if ``record`` names a field absent from the schema. + """ + if not isinstance(record, Mapping): + raise TypeError( + f"swe+proto encode expects a mapping result record, got " + f"{type(record).__name__}.") + msg = self._message_cls() + for name, value in record.items(): + if name in ENVELOPE_FIELD_NAMES: + continue + field = self._fields_by_name.get(name) + if field is None: + raise KeyError( + f"swe+proto: result field {name!r} not in message " + f"{self._descriptor.full_name!r}. Known result fields: " + f"{self.result_field_names}") + self._set_field(msg, field, value) + if envelope: + for name, value in envelope.items(): + if value is None: + continue + field = self._fields_by_name.get(name) + if field is None: + continue # envelope key the descriptor doesn't carry + self._set_field(msg, field, value) + return msg.SerializeToString() + + def _set_field(self, msg, field, value: Any) -> None: + if field.is_repeated: + # DataArray — the node wraps it as `Array { repeated = 1 }`, + # so the repeated field lives one message-level down; handle every + # element kind (scalar / message / enum / timestamp). + self._set_repeated(msg, field, value) + return + if _is_timestamp(field): + _set_timestamp(getattr(msg, field.name), value) + return + if field.message_type is not None: + # Nested record / vector — recurse into the submessage. The + # node emits these as `Rec` / `Vec` messages; the wire is + # driven entirely by field structure, so we walk the descriptor. + self._set_message(getattr(msg, field.name), field.message_type, value) + return + if field.enum_type is not None: + # Constrained Category — accept the token string and map it to the + # enum number via the descriptor (an int passes through). + setattr(msg, field.name, self._enum_number(field, value)) + return + setattr(msg, field.name, value) + + def _enum_number(self, field, value) -> int: + if isinstance(value, str): + enum_value = field.enum_type.values_by_name.get(value) + if enum_value is None: + raise KeyError( + f"swe+proto: {value!r} is not an allowed token for enum " + f"field {field.name!r}. Allowed: " + f"{list(field.enum_type.values_by_name)}") + return enum_value.number + return int(value) + + def _set_repeated(self, msg, field, values) -> None: + if not isinstance(values, (list, tuple)): + raise TypeError( + f"swe+proto: repeated field {field.name!r} requires a " + f"list/tuple, got {type(values).__name__}.") + container = getattr(msg, field.name) + for item in values: + if _is_timestamp(field): + _set_timestamp(container.add(), item) + elif field.message_type is not None: + self._set_message(container.add(), field.message_type, item) + elif field.enum_type is not None: + container.append(self._enum_number(field, item)) + else: + container.append(item) + + def _set_message(self, submsg, descriptor, value: Any) -> None: + """Populate a nested message from a mapping (by field name) or a + sequence (positionally — e.g. a Vector given as ``[x, y, z]``).""" + if isinstance(value, Mapping): + by_name = {f.name: f for f in descriptor.fields} + for key, sub_value in value.items(): + sub_field = by_name.get(key) + if sub_field is None: + raise KeyError( + f"swe+proto: field {key!r} not in nested message " + f"{descriptor.full_name!r}. Known fields: {list(by_name)}") + self._set_field(submsg, sub_field, sub_value) + elif isinstance(value, (list, tuple)): + fields = list(descriptor.fields) + if len(value) != len(fields): + raise ValueError( + f"swe+proto: nested message {descriptor.full_name!r} has " + f"{len(fields)} fields but {len(value)} values were given.") + for sub_field, sub_value in zip(fields, value): + self._set_field(submsg, sub_field, sub_value) 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: + f"swe+proto: nested message {descriptor.full_name!r} requires a " + f"mapping or sequence value, got {type(value).__name__}.") + + # -- decode ------------------------------------------------------------ + + def decode(self, buf: bytes) -> Dict[str, Any]: + """Decode wire bytes into the result record dict (fields 6+). + + Mirrors ``SWEBinaryCodec.decode`` — returns only the result + fields, keyed by name, so the dict drops straight into + ``ObservationResource.result``. Use :meth:`decode_with_envelope` + to also recover the observation metadata. + """ + msg = self._message_cls() + msg.ParseFromString(buf) + return {f.name: self._get_field(msg, f) for f in self._result_fields} + + def decode_with_envelope(self, buf: bytes) -> Dict[str, Any]: + """Decode wire bytes into ``{"result": {...}, }``. + + The metadata keys use CS API JSON spellings (``datastream@id``, + ``phenomenonTime``, ``resultTime``, ``foi@id``) and timestamps + come back as ISO 8601 strings, so the block feeds directly into + ``ObservationResource`` / ``ObservationOMJSONInline``. + """ + msg = self._message_cls() + msg.ParseFromString(buf) + out: Dict[str, Any] = { + "result": {f.name: self._get_field(msg, f) for f in self._result_fields} + } + for name in ENVELOPE_FIELD_NAMES: + field = self._fields_by_name.get(name) + if field is None: continue - out[field_schema.name] = _schema_aware_decode( - field_schema, - getattr(named.component.inline, - _dispatch_table()[type(field_schema)][0]), - ) + out[_ENVELOPE_OUT_KEYS[name]] = self._get_field(msg, field) 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__}.") + + def _get_field(self, msg, field) -> Any: + if field.is_repeated: + return self._get_repeated(msg, field) + if _is_timestamp(field): + return getattr(msg, field.name).ToJsonString() + if field.message_type is not None: + # Nested record / vector — recurse into the submessage and + # return a nested dict keyed by field name. + sub = getattr(msg, field.name) + return {f.name: self._get_field(sub, f) for f in field.message_type.fields} + if field.enum_type is not None: + # Constrained Category — return the token string (matching the + # sibling SWE codecs), falling back to the raw int for an + # unknown value (proto3 enums are open). + return self._enum_name(field, getattr(msg, field.name)) + return getattr(msg, field.name) + + def _enum_name(self, field, number): + enum_value = field.enum_type.values_by_number.get(number) + return enum_value.name if enum_value is not None else number + + def _get_repeated(self, msg, field) -> list: + container = getattr(msg, field.name) + if _is_timestamp(field): + return [ts.ToJsonString() for ts in container] + if field.message_type is not None: + return [{f.name: self._get_field(item, f) + for f in field.message_type.fields} for item in container] + if field.enum_type is not None: + return [self._enum_name(field, n) for n in container] + return list(container) diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..ca98a40 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,126 @@ +"""Shared test helpers. + +Centralizes the three patterns that used to be copy-pasted across the +suite: + +- :class:`MockResponse` + :func:`capture_request` — intercept the + ``requests.`` calls that every CS API code path funnels through + (``oshconnect.csapi4py.request_wrappers``) and record the kwargs. +- ``make_system`` / ``make_datastream`` / ``make_controlstream`` — + build minimal streamable resources wired to a node. +- :func:`osh_node_reachable` — reachability probe used to skip + network-marked tests instead of hanging against a dead server. +""" +from __future__ import annotations + +import json + +import requests + +from oshconnect import ControlStream, Datastream, System +from oshconnect.resource_datamodels import ( + ControlStreamResource, + DatastreamResource, +) + + +# --------------------------------------------------------------------------- +# HTTP mocking +# --------------------------------------------------------------------------- + +class MockResponse: + """Stand-in for ``requests.Response`` — a superset of every per-file + variant it replaced (``status_code``, ``ok``, ``text``, ``headers``, + ``json()``, ``raise_for_status()``).""" + + def __init__(self, payload: dict | None = None, status: int = 200, + headers: dict | None = None): + self._payload = payload if payload is not None else {} + self.status_code = status + self.ok = 200 <= status < 300 + self.text = json.dumps(self._payload) if payload is not None else "" + self.headers = headers or {} + + def raise_for_status(self): + if not self.ok: + raise requests.HTTPError(f"{self.status_code} for url") + + def json(self): + return self._payload + + +def capture_request(monkeypatch, verb: str, + response: MockResponse | None = None) -> dict: + """Patch ``oshconnect.csapi4py.request_wrappers.requests.`` — + the single point all CS API HTTP calls funnel through — and return a + dict that fills with the kwargs of the (last) intercepted call. + + Usage:: + + captured = capture_request(monkeypatch, "post", + response=MockResponse(status=201)) + ...trigger code under test... + assert captured["url"].endswith("/systems") + + Captured keys: ``called``, ``url`` (str), ``params``, ``headers``, + ``auth``, ``data``, ``json``. + """ + captured: dict = {} + resp = response if response is not None else MockResponse() + + def _f(url, params=None, headers=None, auth=None, data=None, json=None, + **kwargs): + captured.update(called=True, url=str(url), params=params, + headers=headers, auth=auth, data=data, json=json) + return resp + + monkeypatch.setattr( + f"oshconnect.csapi4py.request_wrappers.requests.{verb}", _f) + return captured + + +# --------------------------------------------------------------------------- +# Resource factories +# --------------------------------------------------------------------------- + +def make_system(node, label: str = "Test System", + urn: str = "urn:test:system", + resource_id: str = "sys-test-1") -> System: + return System(label=label, urn=urn, parent_node=node, + resource_id=resource_id) + + +def make_datastream(node, ds_id: str = "ds-test-1", + name: str = "Test Datastream") -> Datastream: + ds_resource = DatastreamResource.model_validate({ + "id": ds_id, + "name": name, + "validTime": ["2024-01-01T00:00:00Z", "2025-01-01T00:00:00Z"], + }) + return Datastream(parent_node=node, datastream_resource=ds_resource) + + +def make_controlstream(node, cs_id: str = "cs-test-1", + name: str = "Test ControlStream") -> ControlStream: + cs_resource = ControlStreamResource.model_validate({ + "id": cs_id, + "name": name, + }) + return ControlStream(node=node, controlstream_resource=cs_resource) + + +# --------------------------------------------------------------------------- +# Network reachability (for ``-m network`` tests) +# --------------------------------------------------------------------------- + +def osh_node_reachable(port: int = 8282, *, host: str = "localhost", + path: str = "/sensorhub/api/", + auth: tuple | None = ("admin", "admin"), + timeout: float = 2.0) -> bool: + """True if an OSH node answers with a status in [200, 400).""" + try: + resp = requests.get(f"http://{host}:{port}{path}", + auth=auth, timeout=timeout) + return 200 <= resp.status_code < 400 + except (requests.RequestException, OSError): + return False diff --git a/tests/test_api_helpers_auth.py b/tests/test_api_helpers_auth.py index 510152a..82b6f8c 100644 --- a/tests/test_api_helpers_auth.py +++ b/tests/test_api_helpers_auth.py @@ -1,68 +1,24 @@ """Auth and request-routing tests for the free helpers in -``oshconnect.api_helpers`` and the ``ConnectedSystemsRequestBuilder``. +``oshconnect.api_helpers``. 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 +into ``oshconnect.csapi4py.request_wrappers``. Tests intercept the +underlying ``requests.`` calls with ``tests.helpers.capture_request`` +to verify that ``auth`` flows through as a tuple, not a leaked ``(None, None)`` placeholder. + +Builder-level auth behaviour (``with_auth`` / ``with_basic_auth`` and the +(None, None) carve-out) is covered in +``tests/test_con_sys_api.py::TestBuilderAuth``. """ 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") +from tests.helpers import MockResponse, capture_request def test_retrieve_datastream_schema_plumbs_auth(monkeypatch): - captured: dict = {} - monkeypatch.setattr( - "oshconnect.csapi4py.request_wrappers.requests.get", _capture(captured), - ) + captured = capture_request(monkeypatch, "get") api_helpers.retrieve_datastream_schema( "http://localhost:8282/sensorhub", "ds-id", auth=("alice", "pw"), @@ -73,10 +29,7 @@ def test_retrieve_datastream_schema_plumbs_auth(monkeypatch): def test_retrieve_datastream_schema_omits_auth_when_none(monkeypatch): - captured: dict = {} - monkeypatch.setattr( - "oshconnect.csapi4py.request_wrappers.requests.get", _capture(captured), - ) + captured = capture_request(monkeypatch, "get") api_helpers.retrieve_datastream_schema( "http://localhost:8282/sensorhub", "ds-id", ) @@ -87,25 +40,19 @@ 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), - ) + captured = capture_request(monkeypatch, "get") resp = api_helpers.retrieve_system_by_id( "http://localhost:8282/sensorhub", "sys-id", auth=("u", "p"), ) - assert isinstance(resp, _MockResponse) + 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), - ) + captured = capture_request(monkeypatch, "post") api_helpers.create_new_systems( "http://localhost:8282/sensorhub", request_body={"name": "x"}, @@ -117,13 +64,10 @@ def test_create_new_systems_uses_auth_tuple(monkeypatch): 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), - ) + captured = capture_request(monkeypatch, "get") resp = api_helpers.list_all_systems_in_collection( "http://localhost:8282/sensorhub", "col-id", auth=("u", "p"), ) - assert isinstance(resp, _MockResponse) + assert isinstance(resp, MockResponse) assert captured["auth"] == ("u", "p") diff --git a/tests/test_con_sys_api.py b/tests/test_con_sys_api.py index 52cec0a..9349fb9 100644 --- a/tests/test_con_sys_api.py +++ b/tests/test_con_sys_api.py @@ -3,9 +3,8 @@ 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. +intercepted with ``tests.helpers.capture_request`` 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 @@ -24,32 +23,7 @@ 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 +from tests.helpers import capture_request # --------------------------------------------------------------------------- @@ -91,11 +65,7 @@ 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), - ) + captured = capture_request(monkeypatch, "get") ConnectedSystemAPIRequest( url="http://localhost:8282/sensorhub/api/systems", request_method="GET", @@ -110,11 +80,7 @@ def test_get_routes_to_requests_get(self, monkeypatch): 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), - ) + captured = capture_request(monkeypatch, "post") ConnectedSystemAPIRequest( url="http://localhost:8282/sensorhub/api/systems", request_method="POST", @@ -127,11 +93,7 @@ def test_post_routes_to_requests_post_with_body(self, monkeypatch): 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), - ) + captured = capture_request(monkeypatch, "post") ConnectedSystemAPIRequest( url="http://localhost:8282/sensorhub/api/systems", request_method="POST", @@ -141,11 +103,7 @@ def test_post_routes_dict_body_to_json(self, monkeypatch): 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), - ) + captured = capture_request(monkeypatch, "put") ConnectedSystemAPIRequest( url="http://localhost:8282/sensorhub/api/systems/sys-1", request_method="PUT", @@ -155,11 +113,7 @@ def test_put_routes_to_requests_put(self, monkeypatch): 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), - ) + captured = capture_request(monkeypatch, "delete") ConnectedSystemAPIRequest( url="http://localhost:8282/sensorhub/api/systems/sys-1", request_method="DELETE", @@ -201,11 +155,7 @@ def test_get_with_body_raises(self): req.make_request() def test_get_without_body_dispatches(self, monkeypatch): - captured: dict = {} - monkeypatch.setattr( - "oshconnect.csapi4py.request_wrappers.requests.get", - _capture(captured), - ) + captured = capture_request(monkeypatch, "get") ConnectedSystemAPIRequest( url="http://localhost/api/systems", request_method="GET", @@ -214,11 +164,7 @@ def test_get_without_body_dispatches(self, monkeypatch): 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), - ) + captured = capture_request(monkeypatch, "post") ConnectedSystemAPIRequest( url="http://localhost/api/systems/sys-1/actions/reset", request_method="POST", @@ -228,11 +174,7 @@ def test_post_without_body_dispatches(self, monkeypatch): 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), - ) + captured = capture_request(monkeypatch, "post") ConnectedSystemAPIRequest( url="http://localhost/api/systems", request_method="POST", @@ -241,11 +183,7 @@ def test_post_with_body_dispatches(self, monkeypatch): 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), - ) + captured = capture_request(monkeypatch, "put") ConnectedSystemAPIRequest( url="http://localhost/api/systems/sys-1", request_method="PUT", @@ -254,11 +192,7 @@ def test_put_with_body_dispatches(self, monkeypatch): 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), - ) + captured = capture_request(monkeypatch, "delete") ConnectedSystemAPIRequest( url="http://localhost/api/systems/sys-1", request_method="DELETE", @@ -268,11 +202,7 @@ def test_delete_without_body_dispatches(self, monkeypatch): 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), - ) + captured = capture_request(monkeypatch, "delete") ConnectedSystemAPIRequest( url="http://localhost/api/systems/sys-1", request_method="DELETE", @@ -466,11 +396,7 @@ def test_no_body_field(self): 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), - ) + captured = capture_request(monkeypatch, "get") GetRequest( url="http://localhost/api/systems", params={"f": "json"}, @@ -494,11 +420,7 @@ def test_no_params_field(self): 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), - ) + captured = capture_request(monkeypatch, "post") PostRequest( url="http://localhost/api/systems", body='{"name": "x"}', @@ -507,11 +429,7 @@ def test_execute_with_str_body_routes_to_data(self, monkeypatch): 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), - ) + captured = capture_request(monkeypatch, "post") PostRequest( url="http://localhost/api/systems", body={"name": "x"}, @@ -520,11 +438,7 @@ def test_execute_with_dict_body_routes_to_json(self, monkeypatch): 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), - ) + captured = capture_request(monkeypatch, "post") PostRequest(url="http://localhost/api/x/actions/reset").execute() assert captured["called"] is True @@ -538,11 +452,7 @@ 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), - ) + captured = capture_request(monkeypatch, "put") PutRequest( url="http://localhost/api/systems/sys-1", body='{"name": "renamed"}', @@ -561,11 +471,7 @@ def test_no_body_field(self): 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), - ) + captured = capture_request(monkeypatch, "delete") DeleteRequest( url="http://localhost/api/systems/sys-1", auth=("u", "p"), diff --git a/tests/test_controlstream_insert_schema.py b/tests/test_controlstream_insert_schema.py index bd58e8d..75677b9 100644 --- a/tests/test_controlstream_insert_schema.py +++ b/tests/test_controlstream_insert_schema.py @@ -20,23 +20,17 @@ from oshconnect import Node, System from oshconnect.api_utils import URI, UCUMCode from oshconnect.swe_components import DataRecordSchema, QuantitySchema, TimeSchema +from tests.helpers import MockResponse, capture_request -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 _capture_insert_post(monkeypatch) -> dict: + """Intercept the controlstream POST with a 201 + ``Location`` response + (``insert`` paths read the new resource id off that header).""" + return capture_request(monkeypatch, "post", response=MockResponse( + status=201, + headers={"Location": + "http://localhost:8585/sensorhub/api/controlstreams/cs-new"}, + )) def _record_schema() -> DataRecordSchema: @@ -88,10 +82,7 @@ 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), - ) + captured = _capture_insert_post(monkeypatch) system.add_and_insert_control_stream(_record_schema()) @@ -111,10 +102,7 @@ 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), - ) + captured = _capture_insert_post(monkeypatch) system.add_and_insert_control_stream( _record_schema(), command_format="application/swe+json", diff --git a/tests/test_csapi_serialization.py b/tests/test_csapi_serialization.py index 0d18457..3d26d0e 100644 --- a/tests/test_csapi_serialization.py +++ b/tests/test_csapi_serialization.py @@ -32,12 +32,11 @@ JSONCommandSchema, OMJSONDatastreamRecordSchema, LogicalDatastreamRecordSchema, - ObservationOMJSONInline, SWEDatastreamRecordSchema, - SWEJSONCommandSchema, ) from oshconnect.streamableresource import ControlStream, Datastream, System from oshconnect.timemanagement import TimeInstant, TimePeriod +from tests.helpers import MockResponse, capture_request FIXTURES_DIR = Path(__file__).parent / "fixtures" @@ -274,22 +273,6 @@ def test_system_init_with_name_kwarg_routes_to_label_with_warning(node): # 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 @@ -305,11 +288,11 @@ def test_insert_self_strips_id_and_links_from_body(node, monkeypatch): 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), - ) + captured = capture_request(monkeypatch, "post", response=MockResponse( + status=201, + headers={"Location": + "http://localhost:8282/sensorhub/api/systems/dest-id-xyz"}, + )) sys.insert_self() @@ -543,86 +526,37 @@ def test_logical_schema_permissive_extra_fields(): assert dumped["properties"]["x"]["minimum"] == 0 -def test_retrieve_datastream_schema_logical_obsformat(monkeypatch): +@pytest.mark.parametrize("fixture_name, obs_format, schema_cls, parse", [ + ("fake_weather_schema_logical.json", "logical", + LogicalDatastreamRecordSchema, + LogicalDatastreamRecordSchema.from_logical_dict), + ("fake_weather_schema_swejson.json", "application/swe+json", + SWEDatastreamRecordSchema, + SWEDatastreamRecordSchema.from_swejson_dict), +]) +def test_retrieve_datastream_schema_by_obsformat( + monkeypatch, fixture_name, obs_format, schema_cls, parse): """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``. + the ``?obsFormat=...`` query, and that the body parses as the + matching schema model. """ from oshconnect.api_helpers import retrieve_datastream_schema - raw = json.loads((FIXTURES_DIR / "fake_weather_schema_logical.json").read_text()) - - captured = {} - - class _MockResponse: - status_code = 200 - - def raise_for_status(self): - pass - - def json(self): - return raw - - def _mock_get(url, params=None, headers=None, auth=None, **kwargs): - captured["url"] = str(url) - captured["params"] = params - captured["auth"] = auth - return _MockResponse() - - monkeypatch.setattr( - "oshconnect.csapi4py.request_wrappers.requests.get", _mock_get, - ) + raw = json.loads((FIXTURES_DIR / fixture_name).read_text()) + captured = capture_request(monkeypatch, "get", + response=MockResponse(payload=raw)) resp = retrieve_datastream_schema( "http://localhost:8282/sensorhub", "038s1ic7k460", - obs_format="logical", + obs_format=obs_format, ) - schema = LogicalDatastreamRecordSchema.from_logical_dict(resp.json()) + schema = parse(resp.json()) - assert isinstance(schema, LogicalDatastreamRecordSchema) - assert schema.title == "New Simulated Weather Sensor - weather" + assert isinstance(schema, schema_cls) assert captured["url"].endswith("/datastreams/038s1ic7k460/schema") - assert captured["params"] == {"obsFormat": "logical"} - - -def test_retrieve_datastream_schema_swejson_obsformat(monkeypatch): - """Symmetric to the logical-format test: SWE+JSON variant goes - through the same ``retrieve_datastream_schema`` helper, picked via - ``obs_format='application/swe+json'``. The body parses as - ``SWEDatastreamRecordSchema``. - """ - from oshconnect.api_helpers import retrieve_datastream_schema - - raw = json.loads((FIXTURES_DIR / "fake_weather_schema_swejson.json").read_text()) - - captured = {} - - class _MockResponse: - status_code = 200 - - def raise_for_status(self): - pass - - def json(self): - return raw - - def _mock_get(url, params=None, headers=None, auth=None, **kwargs): - captured["params"] = params - return _MockResponse() - - monkeypatch.setattr( - "oshconnect.csapi4py.request_wrappers.requests.get", _mock_get, - ) - - resp = retrieve_datastream_schema( - "http://localhost:8282/sensorhub", "ds-x", - obs_format="application/swe+json", - ) - schema = SWEDatastreamRecordSchema.from_swejson_dict(resp.json()) - assert isinstance(schema, SWEDatastreamRecordSchema) - assert captured["params"] == {"obsFormat": "application/swe+json"} + assert captured["params"] == {"obsFormat": obs_format} def test_observation_to_omjson_round_trips(): diff --git a/tests/test_datastore.py b/tests/test_datastore.py index e95e288..add855d 100644 --- a/tests/test_datastore.py +++ b/tests/test_datastore.py @@ -9,19 +9,10 @@ import pytest -from src.oshconnect import OSHConnect -from src.oshconnect.datastores import SQLiteDataStore -from src.oshconnect.resource_datamodels import ( - ControlStreamResource, - DatastreamResource, -) -from src.oshconnect.streamableresource import ( - ControlStream, - Datastream, - Node, - SessionManager, - System, -) +from oshconnect import OSHConnect +from oshconnect.datastores import SQLiteDataStore +from oshconnect.streamableresource import Node, SessionManager, System +from tests.helpers import make_controlstream, make_datastream, make_system # --------------------------------------------------------------------------- @@ -43,37 +34,19 @@ def make_node(sm: SessionManager = None) -> Node: return node -def make_system(node: Node) -> System: - return System( - label="Test System", - urn="urn:test:sensors:sys1", - parent_node=node, - resource_id="sys001", - ) - - -def make_datastream(node: Node) -> Datastream: - ds_resource = DatastreamResource.model_validate({ - "id": "ds001", - "name": "Test Datastream", - "validTime": ["2024-01-01T00:00:00Z", "2025-01-01T00:00:00Z"], - }) - return Datastream(parent_node=node, datastream_resource=ds_resource) - - -def make_controlstream(node: Node) -> ControlStream: - cs_resource = ControlStreamResource.model_validate({ - "id": "cs001", - "name": "Test ControlStream", - }) - return ControlStream(node=node, controlstream_resource=cs_resource) - - # --------------------------------------------------------------------------- # Node round-trip # --------------------------------------------------------------------------- class TestNodeRoundTrip: + def test_node_password_round_trips_through_storage_dict(self): + node = Node(protocol='http', address='localhost', port=8080, + username='user', password='pass') + stored = node.to_storage_dict() + assert stored['password'] == 'pass' + rehydrated = Node.from_storage_dict(stored) + assert rehydrated._api_helper.password == 'pass' + def test_save_and_load_node(self): store = SQLiteDataStore(":memory:") sm = SessionManager() diff --git a/tests/test_default_api_helpers.py b/tests/test_default_api_helpers.py index 99b8462..dbc83fe 100644 --- a/tests/test_default_api_helpers.py +++ b/tests/test_default_api_helpers.py @@ -3,10 +3,9 @@ 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. +``tests.helpers.capture_request`` 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 @@ -22,6 +21,7 @@ determine_parent_type, resource_type_to_endpoint, ) +from tests.helpers import capture_request # --------------------------------------------------------------------------- @@ -315,38 +315,12 @@ def test_topic_with_subresource_id_appends_after_data_suffix(self): # --------------------------------------------------------------------------- -# APIHelper HTTP methods (monkeypatch requests.{verb}) +# APIHelper HTTP methods (capture_request patches 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), - ) + captured = capture_request(monkeypatch, "post") 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" @@ -354,11 +328,7 @@ def test_top_level_post_url_and_body(self, monkeypatch): 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), - ) + captured = capture_request(monkeypatch, "post") helper = _make_helper() helper.create_resource( APIResourceTypes.DATASTREAM, '{"name": "x"}', @@ -373,11 +343,7 @@ 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), - ) + captured = capture_request(monkeypatch, "post") helper = _make_helper() helper.create_resource( APIResourceTypes.SYSTEM, '{}', url_endpoint="custom/path", @@ -390,11 +356,7 @@ def test_url_endpoint_override(self, monkeypatch): class TestRetrieveResource: def test_retrieve_with_id(self, monkeypatch): - captured: dict = {} - monkeypatch.setattr( - "oshconnect.csapi4py.request_wrappers.requests.get", - _capture(captured), - ) + captured = capture_request(monkeypatch, "get") helper = _make_helper() helper.retrieve_resource(APIResourceTypes.SYSTEM, res_id="sys-1") assert ( @@ -403,11 +365,7 @@ def test_retrieve_with_id(self, monkeypatch): ) def test_retrieve_collection_when_id_omitted(self, monkeypatch): - captured: dict = {} - monkeypatch.setattr( - "oshconnect.csapi4py.request_wrappers.requests.get", - _capture(captured), - ) + captured = capture_request(monkeypatch, "get") helper = _make_helper() helper.retrieve_resource(APIResourceTypes.SYSTEM) assert captured["url"].endswith("/systems") @@ -415,21 +373,13 @@ def test_retrieve_collection_when_id_omitted(self, monkeypatch): class TestGetResource: def test_resource_type_only(self, monkeypatch): - captured: dict = {} - monkeypatch.setattr( - "oshconnect.csapi4py.request_wrappers.requests.get", - _capture(captured), - ) + captured = capture_request(monkeypatch, "get") 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), - ) + captured = capture_request(monkeypatch, "get") helper = _make_helper() helper.get_resource( APIResourceTypes.DATASTREAM, @@ -439,11 +389,7 @@ def test_resource_with_id_and_subresource(self, monkeypatch): 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), - ) + captured = capture_request(monkeypatch, "get") helper = _make_helper() helper.get_resource( APIResourceTypes.CONTROL_CHANNEL, @@ -458,11 +404,7 @@ 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), - ) + captured = capture_request(monkeypatch, "put") helper = _make_helper() helper.update_resource( APIResourceTypes.SYSTEM, "sys-1", '{"name": "renamed"}', @@ -474,11 +416,7 @@ def test_top_level_put_includes_res_id(self, monkeypatch): 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), - ) + captured = capture_request(monkeypatch, "put") helper = _make_helper() helper.update_resource( APIResourceTypes.DATASTREAM, "ds-1", "{}", @@ -491,11 +429,7 @@ 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), - ) + captured = capture_request(monkeypatch, "delete") helper = _make_helper() helper.delete_resource(APIResourceTypes.SYSTEM, "sys-1") assert ( @@ -504,11 +438,7 @@ def test_top_level_delete_includes_res_id(self, monkeypatch): ), "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), - ) + captured = capture_request(monkeypatch, "delete") helper = _make_helper() helper.delete_resource( APIResourceTypes.DATASTREAM, "ds-1", parent_res_id="sys-1", @@ -516,11 +446,7 @@ def test_subresource_delete_includes_both_ids(self, monkeypatch): 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), - ) + captured = capture_request(monkeypatch, "delete") helper = _make_helper(username="admin", password="s3cret", user_auth=True) helper.delete_resource(APIResourceTypes.SYSTEM, "sys-1") assert captured["auth"] == ("admin", "s3cret") diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 3edbdf3..a842ac3 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -25,6 +25,7 @@ from oshconnect.schema_datamodels import SWEDatastreamRecordSchema from oshconnect.streamableresource import SchemaFetchWarning from oshconnect.timemanagement import TimePeriod +from tests.helpers import MockResponse FIXTURES_DIR = Path(__file__).parent / "fixtures" @@ -103,30 +104,13 @@ def _listing_payload(*ds_ids: str) -> dict: } -class _MockResponse: - def __init__(self, payload: dict, status: int = 200): - self._payload = payload - self.status_code = status - self.ok = 200 <= status < 300 - self.headers = {} - self.text = json.dumps(payload) - - def raise_for_status(self): - if not self.ok: - from requests import HTTPError - raise HTTPError(f"{self.status_code} for url") - - def json(self): - return self._payload - - def _install_dispatching_get(monkeypatch, listing_payload, schema_handler): """Patch ``requests.get`` at the single point both discovery calls funnel through (``oshconnect.csapi4py.request_wrappers.requests.get``). Both the system-scoped listing and the per-datastream schema fetch now go through ``APIHelper.get_resource`` → ``make_request``. - ``schema_handler(ds_id) -> _MockResponse`` is invoked per-datastream + ``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): @@ -135,7 +119,7 @@ def mock_get(url, params=None, headers=None, auth=None, **kwargs): ds_id = url_str.rsplit("/", 2)[-2] return schema_handler(ds_id) # Fallback: the system-scoped listing - return _MockResponse(listing_payload) + return MockResponse(listing_payload) monkeypatch.setattr( "oshconnect.csapi4py.request_wrappers.requests.get", mock_get, @@ -153,7 +137,7 @@ def test_discover_datastreams_populates_record_schema(node, monkeypatch): _install_dispatching_get( monkeypatch, listing_payload=_listing_payload("ds-1"), - schema_handler=lambda ds_id: _MockResponse(swe_schema), + schema_handler=lambda ds_id: MockResponse(swe_schema), ) sys = System(label="S", urn="urn:test:s", @@ -180,8 +164,8 @@ def test_discover_datastreams_continues_on_schema_fetch_failure(node, monkeypatc def schema_handler(ds_id): if ds_id == "ds-broken": - return _MockResponse({"error": "boom"}, status=500) - return _MockResponse(swe_schema) + return MockResponse({"error": "boom"}, status=500) + return MockResponse(swe_schema) _install_dispatching_get( monkeypatch, @@ -216,8 +200,8 @@ def test_discover_datastreams_logs_traceback_on_schema_failure(node, monkeypatch def schema_handler(ds_id): if ds_id == "ds-broken": - return _MockResponse({"error": "boom"}, status=500) - return _MockResponse(swe_schema) + return MockResponse({"error": "boom"}, status=500) + return MockResponse(swe_schema) _install_dispatching_get( monkeypatch, @@ -258,7 +242,7 @@ def test_discover_systems_pins_sml_json_format(node, monkeypatch): def mock_get(url, params=None, headers=None, auth=None, **kwargs): captured["url"] = str(url) captured["params"] = params - return _MockResponse({"items": []}) + return MockResponse({"items": []}) monkeypatch.setattr( "oshconnect.csapi4py.request_wrappers.requests.get", mock_get, @@ -304,7 +288,7 @@ def test_discover_systems_binds_full_underlying_resource_from_sml(node, monkeypa } def mock_get(url, params=None, headers=None, auth=None, **kwargs): - return _MockResponse(listing) + return MockResponse(listing) monkeypatch.setattr( "oshconnect.csapi4py.request_wrappers.requests.get", mock_get, @@ -357,7 +341,7 @@ def test_discover_systems_still_handles_geojson_fallback(node, monkeypatch): } def mock_get(url, params=None, headers=None, auth=None, **kwargs): - return _MockResponse(listing) + return MockResponse(listing) monkeypatch.setattr( "oshconnect.csapi4py.request_wrappers.requests.get", mock_get, diff --git a/tests/test_imports.py b/tests/test_imports.py index 9f7bbae..5afd2b0 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -26,6 +26,10 @@ "ConnectedSystemAPIRequest"]), ("oshconnect.csapi4py", ["MQTTCommClient"]), ("oshconnect.csapi4py", ["APIHelper"]), + # Back-compat shim — all event symbols now live in oshconnect.events. + ("oshconnect.eventbus", ["EventHandler", "Event", "EventBuilder", + "IEventListener", "CallbackListener", + "DefaultEventTypes", "AtomicEventTypes"]), ] diff --git a/tests/test_mqtt_topics.py b/tests/test_mqtt_topics.py index 88bc145..9283dab 100644 --- a/tests/test_mqtt_topics.py +++ b/tests/test_mqtt_topics.py @@ -12,10 +12,9 @@ import pytest from unittest.mock import MagicMock -from src.oshconnect.csapi4py.constants import APIResourceTypes -from src.oshconnect.csapi4py.default_api_helpers import APIHelper -from src.oshconnect.resource_datamodels import DatastreamResource, ControlStreamResource, SystemResource -from src.oshconnect.streamableresource import Datastream, ControlStream, System +from oshconnect.csapi4py.constants import APIResourceTypes +from oshconnect.csapi4py.default_api_helpers import APIHelper +from tests import helpers DS_ID = "ds_test_001" CS_ID = "cs_test_001" @@ -39,33 +38,19 @@ def make_mock_node(api_root="api", mqtt_topic_root=None): return node +# Thin wrappers over the shared factories in ``tests.helpers`` that fill in +# this module's mock node and topic-id constants. + def make_datastream(node=None): - if node is None: - node = make_mock_node() - ds_resource = DatastreamResource.model_validate({ - "id": DS_ID, - "name": "Test Datastream", - "validTime": ["2024-01-01T00:00:00Z", "2025-01-01T00:00:00Z"], - }) - return Datastream(parent_node=node, datastream_resource=ds_resource) + return helpers.make_datastream(node or make_mock_node(), ds_id=DS_ID) def make_controlstream(node=None): - if node is None: - node = make_mock_node() - cs_resource = ControlStreamResource.model_validate({ - "id": CS_ID, - "name": "Test ControlStream", - }) - return ControlStream(node=node, controlstream_resource=cs_resource) + return helpers.make_controlstream(node or make_mock_node(), cs_id=CS_ID) def make_system(node=None): - if node is None: - node = make_mock_node() - sys = System(label="Test System", urn="urn:test:system", parent_node=node, - resource_id=SYS_ID) - return sys + return helpers.make_system(node or make_mock_node(), resource_id=SYS_ID) class TestDatastreamTopics: @@ -324,15 +309,16 @@ class TestDataTopicFormatSubtopic: ("application/swe+json", "swe-json"), ("application/swe+binary", "swe-binary"), ("application/swe+csv", "swe-csv"), + ("application/swe+proto", "swe-proto"), ("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 + from 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 + from 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") @@ -387,7 +373,7 @@ def test_get_mqtt_topic_ignores_format_on_event_topic(self): 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 + from oshconnect.schema_datamodels import SWEBinaryDatastreamRecordSchema node = make_mock_node() node.get_mqtt_client.return_value = MagicMock() ds = make_datastream(node) @@ -400,7 +386,7 @@ def test_datastream_init_mqtt_with_swe_binary_schema_appends_token(self): 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 + from oshconnect.schema_datamodels import SWEDatastreamRecordSchema node = make_mock_node() node.get_mqtt_client.return_value = MagicMock() ds = make_datastream(node) @@ -423,7 +409,7 @@ def test_datastream_init_mqtt_without_schema_stays_bare(self): 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 + from oshconnect.schema_datamodels import SWEJSONCommandSchema node = make_mock_node() node.get_mqtt_client.return_value = MagicMock() cs = make_controlstream(node) @@ -436,7 +422,7 @@ def test_controlstream_init_mqtt_with_swe_json_schema_appends_token(self): 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 + from oshconnect.schema_datamodels import JSONCommandSchema node = make_mock_node() node.get_mqtt_client.return_value = MagicMock() cs = make_controlstream(node) diff --git a/tests/test_node.py b/tests/test_node.py deleted file mode 100644 index 104f352..0000000 --- a/tests/test_node.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Node and APIHelper basics: URL construction and (de)serialization.""" -from oshconnect import Node -from oshconnect.csapi4py import APIHelper - - -def test_apihelper_url_generation(): - helper = APIHelper(server_url='localhost', port=8282, protocol='http', - username='admin', password='admin') - - assert helper.get_api_root_url() == "http://localhost:8282/sensorhub/api" - assert helper.get_api_root_url(socket=True) == "ws://localhost:8282/sensorhub/api" - - helper.set_protocol('https') - assert helper.get_api_root_url() == "https://localhost:8282/sensorhub/api" - assert helper.get_api_root_url(socket=True) == "wss://localhost:8282/sensorhub/api" - - -def test_node_password_round_trips_through_storage_dict(): - node = Node(protocol='http', address='localhost', port=8080, - username='user', password='pass') - stored = node.to_storage_dict() - assert stored['password'] == 'pass' - rehydrated = Node.from_storage_dict(stored) - assert rehydrated._api_helper.password == 'pass' \ No newline at end of file diff --git a/tests/test_node_to_node_sync.py b/tests/test_node_to_node_sync.py index 44a8092..e8ab395 100644 --- a/tests/test_node_to_node_sync.py +++ b/tests/test_node_to_node_sync.py @@ -22,33 +22,20 @@ 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, - SWEJSONCommandSchema, ) from oshconnect.timemanagement import TimeInstant, TimePeriod, TimeUtils +from tests.helpers import osh_node_reachable 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, @@ -58,14 +45,14 @@ def _make_node(port: int) -> Node: @pytest.fixture def src_node(): - if not _node_reachable(SRC_PORT): + if not osh_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): + if not osh_node_reachable(DEST_PORT): pytest.skip(f"dest OSH node not reachable at localhost:{DEST_PORT}") return _make_node(DEST_PORT) diff --git a/tests/test_oshconnect.py b/tests/test_oshconnect.py index c8160c2..1397883 100644 --- a/tests/test_oshconnect.py +++ b/tests/test_oshconnect.py @@ -6,6 +6,7 @@ import pytest from oshconnect import Node, OSHConnect +from tests.helpers import osh_node_reachable TEST_PORT = 8282 @@ -28,34 +29,34 @@ def test_oshconnect_add_node_appends_to_nodes_list(): # Live-server tests (network-marked) # --------------------------------------------------------------------------- -@pytest.mark.network -def test_discover_systems_against_live_node(): +@pytest.fixture +def live_app() -> OSHConnect: + """An OSHConnect wired to the live node — skips (instead of hanging) + when no server answers on :8282.""" + if not osh_node_reachable(TEST_PORT): + pytest.skip(f"OSH node not reachable at localhost:{TEST_PORT}") 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}') + app.add_node(Node(address="localhost", port=TEST_PORT, username="admin", + password="admin", protocol="http")) + return app @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 +def test_discover_systems_against_live_node(live_app): + live_app.discover_systems() + print(f'Found systems: {live_app._systems}') @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 +def test_discover_datastreams_against_live_node(live_app): + live_app.discover_systems() + live_app.discover_datastreams() + assert len(live_app._datastreams) > 0 + + +@pytest.mark.network +def test_discover_then_get_datastreams_returns_list(live_app): + live_app.discover_systems() + live_app.discover_datastreams() + datastreams = live_app.get_datastreams() + print(datastreams) diff --git a/tests/test_swe_binary.py b/tests/test_swe_binary.py index 2fcac55..1cec981 100644 --- a/tests/test_swe_binary.py +++ b/tests/test_swe_binary.py @@ -41,6 +41,7 @@ encode_swe_binary_blob, encode_swe_binary_record, ) +from tests.helpers import osh_node_reachable # --------------------------------------------------------------------------- @@ -465,31 +466,28 @@ def test_datastream_decode_observation_without_schema_raises(): # --------------------------------------------------------------------------- -def test_pick_schema_format_prefers_swe_json(): +@pytest.mark.parametrize("available, expected_fmt, expected_parser", [ + # swe+json wins whenever advertised + (["application/om+json", "application/swe+json", "application/swe+binary"], + "application/swe+json", SWEDatastreamRecordSchema.from_swejson_dict), + # falls back to swe+binary + (["application/om+json", "application/swe+binary"], + "application/swe+binary", + SWEBinaryDatastreamRecordSchema.from_swebinary_dict), + # nothing supported → (None, None) + (["application/om+json", "application/swe+csv"], None, None), +]) +def test_pick_schema_format_prefers_best_supported(available, expected_fmt, + expected_parser): 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 + obs_fmt, parser = System._pick_datastream_schema_format(available) + assert obs_fmt == expected_fmt + if expected_parser is None: + assert parser is None + else: + # Bound classmethods aren't identity-equal across accesses; + # compare the underlying functions. + assert parser.__func__ is expected_parser.__func__ # --------------------------------------------------------------------------- @@ -501,16 +499,9 @@ def test_pick_schema_format_returns_none_when_nothing_supported(): 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(), + not osh_node_reachable(int(AXIS_PORT), path="/sensorhub/api/systems", + auth=None), reason=f"Axis OSH node not reachable at {AXIS_BASE}", ) diff --git a/tests/test_swe_components.py b/tests/test_swe_components.py index 08693e8..6dd0f5d 100644 --- a/tests/test_swe_components.py +++ b/tests/test_swe_components.py @@ -322,9 +322,12 @@ def test_swe_datastream_root_invalid_name_pattern_raises(): # here would break record-schema parsing during discovery. -def test_quantity_requires_uom(): - with pytest.raises(ValidationError, match="uom"): - QuantitySchema(label="X", definition="http://example.org/x") +@pytest.mark.parametrize("missing", ["uom", "definition"]) +def test_quantity_required_fields(missing): + base = dict(label="X", definition="http://example.org/x", uom={"code": "m"}) + kwargs = {k: v for k, v in base.items() if k != missing} + with pytest.raises(ValidationError, match=missing): + QuantitySchema(**kwargs) def test_quantity_label_is_optional(): @@ -332,21 +335,11 @@ def test_quantity_label_is_optional(): assert q.label is None -def test_quantity_requires_definition(): +@pytest.mark.parametrize("cls", [BooleanSchema, TextSchema]) +def test_label_optional_definition_required(cls): + cls(definition="http://example.org/x") # no label — OK with pytest.raises(ValidationError, match="definition"): - QuantitySchema(label="X", uom={"code": "m"}) - - -def test_boolean_label_optional_definition_required(): - BooleanSchema(definition="http://example.org/b") # no label — OK - with pytest.raises(ValidationError, match="definition"): - BooleanSchema(label="X") - - -def test_text_label_optional_definition_required(): - TextSchema(definition="http://example.org/t") # no label — OK - with pytest.raises(ValidationError, match="definition"): - TextSchema(label="X") + cls(label="X") def test_vector_requires_definition_referenceframe_coordinates(): @@ -548,27 +541,18 @@ def test_anycomponent_round_trip_through_typeadapter(): # --- 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(): +@pytest.mark.parametrize("coordinate", [ + pytest.param({"type": "Boolean", "name": "flag", "label": "F", + "definition": "http://example.org/f"}, id="boolean"), + pytest.param({"type": "DataRecord", "name": "inner", + "fields": [_quantity_field("a")]}, id="datarecord"), +]) +def test_vector_rejects_non_scalar_numeric_coordinates(coordinate): 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")], - }], + "coordinates": [coordinate], }) diff --git a/tests/test_swe_protobuf.py b/tests/test_swe_protobuf.py index b573e7e..8b52907 100644 --- a/tests/test_swe_protobuf.py +++ b/tests/test_swe_protobuf.py @@ -1,372 +1,892 @@ # ============================================================================= # Copyright (c) 2026 Georobotix Innovative Research -# Date: 2026/5/19 +# Date: 2026/6/8 # Author: Ian Patterson # Contact Email: ian.patterson@georobotix.us # ============================================================================= -"""Tests for the ``application/swe+proto`` codec. +"""Tests for the ``application/swe+proto`` codec (descriptor-driven). -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. +``application/swe+proto`` is the per-datastream descriptor Protobuf +encoding: a DataStream ships a serialized ``FileDescriptorSet`` describing +one ``Observation_``-shaped message (envelope fields 1–5 + the SWE +record at 6+), and each observation is a serialized instance of it. -Default lookup path: ``$BINARY_ENCODINGS_GEN`` (env var) or -``~/IdeaProjects/BinaryEncodings/gen/protobuf``. Override per-run via -the env var. +The codec builds the message class dynamically from that descriptor, so +these tests construct a descriptor inline (no generated BinaryEncodings +bindings needed) — they only require the ``protobuf`` runtime. The +descriptor deliberately uses ``google.protobuf.Timestamp`` for the time +fields so the dependency-seeding path is exercised, matching what a real +node descriptor needs. """ from __future__ import annotations -import importlib -import os -import sys -from pathlib import Path +import base64 +import shutil 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, -) +_HAS_PROTOC = shutil.which("protoc") is not None +protobuf = pytest.importorskip( + "google.protobuf", reason="protobuf runtime not installed (pip install 'oshconnect[protobuf]')") -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 +from google.protobuf import descriptor_pb2 # noqa: E402 +from oshconnect import SWEProtobufCodec, SWEProtobufDatastreamRecordSchema # noqa: E402 -_HAS_PB = _ensure_pb_path() +# --------------------------------------------------------------------------- +# Inline descriptor construction +# --------------------------------------------------------------------------- -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.", -) +_FDP = descriptor_pb2.FieldDescriptorProto +_OPTIONAL = _FDP.LABEL_OPTIONAL -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- +def _add_field(msg, name, number, ftype, type_name=None): + f = msg.field.add() + f.name = name + f.number = number + f.label = _OPTIONAL + f.type = ftype + if type_name is not None: + f.type_name = type_name -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'), - ], +def _weather_descriptor_set(*, package="oshtest.weather", + message="WeatherObservation") -> bytes: + """A per-datastream weather observation message, as a FileDescriptorSet. + + Envelope (1–5): id, datastream_id, foi_id, phenomenon_time, + result_time (the two times are google.protobuf.Timestamp). Result + (6+): air_temperature/relative_humidity (float), samples (int32), + clear_sky (bool), note (string). + """ + fdp = descriptor_pb2.FileDescriptorProto() + fdp.name = "oshtest/weather.proto" + fdp.package = package + fdp.syntax = "proto3" + fdp.dependency.append("google/protobuf/timestamp.proto") + + m = fdp.message_type.add() + m.name = message + _add_field(m, "id", 1, _FDP.TYPE_STRING) + _add_field(m, "datastream_id", 2, _FDP.TYPE_STRING) + _add_field(m, "foi_id", 3, _FDP.TYPE_STRING) + _add_field(m, "phenomenon_time", 4, _FDP.TYPE_MESSAGE, ".google.protobuf.Timestamp") + _add_field(m, "result_time", 5, _FDP.TYPE_MESSAGE, ".google.protobuf.Timestamp") + _add_field(m, "air_temperature", 6, _FDP.TYPE_FLOAT) + _add_field(m, "relative_humidity", 7, _FDP.TYPE_FLOAT) + _add_field(m, "samples", 8, _FDP.TYPE_INT32) + _add_field(m, "clear_sky", 9, _FDP.TYPE_BOOL) + _add_field(m, "note", 10, _FDP.TYPE_STRING) + + fds = descriptor_pb2.FileDescriptorSet() + fds.file.append(fdp) + return fds.SerializeToString() + + +def _weather_schema() -> SWEProtobufDatastreamRecordSchema: + return SWEProtobufDatastreamRecordSchema( + file_descriptor_set=_weather_descriptor_set(), + message_type="oshtest.weather.WeatherObservation", ) +# Result values shared by several round-trip tests. floats are exact in +# binary32 so equality holds after the float64→float32→float64 trip. +_RESULT = { + "air_temperature": 23.5, + "relative_humidity": 60.0, + "samples": 42, + "clear_sky": True, + "note": "sunny", +} + + # --------------------------------------------------------------------------- -# Encoding markers +# Schema model # --------------------------------------------------------------------------- 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()) + schema = _weather_schema() assert isinstance(schema.record_encoding, ProtobufEncoding) assert schema.obs_format == "application/swe+proto" + assert schema.message_type == "oshtest.weather.WeatherObservation" + + +def test_schema_base64_round_trips_through_json_dict(): + """The descriptor must survive a JSON dump/parse as base64 — that's how + it travels in the CS API schema document and the discriminated union.""" + schema = _weather_schema() + doc = schema.to_sweproto_dict() + assert doc["obsFormat"] == "application/swe+proto" + # fileDescriptorSet is base64 text in the JSON form, decoding back to + # the original bytes. + assert base64.b64decode(doc["fileDescriptorSet"]) == schema.file_descriptor_set + parsed = SWEProtobufDatastreamRecordSchema.from_sweproto_dict(doc) + assert parsed.file_descriptor_set == schema.file_descriptor_set + assert parsed.message_type == schema.message_type 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`.""" + """A DatastreamResource carrying a swe+proto schema doc must route the + literal obsFormat to SWEProtobufDatastreamRecordSchema, base64 and all.""" 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), - }, + "schema": _weather_schema().to_sweproto_dict(), "formats": ["application/swe+proto"], } ds = DatastreamResource.model_validate(payload, by_alias=True) assert isinstance(ds.record_schema, SWEProtobufDatastreamRecordSchema) + # And the descriptor survived intact through the union round-trip. + SWEProtobufCodec(ds.record_schema) # builds without error # --------------------------------------------------------------------------- -# Scalar round-trips +# Codec 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', +def test_decode_returns_only_result_fields(): + """decode() yields the result record (6+) keyed by name — matching the + swe+binary codec — and never leaks envelope metadata into it.""" + codec = SWEProtobufCodec(_weather_schema()) + assert set(codec.result_field_names) == set(_RESULT) + wire = codec.encode(_RESULT, envelope={ + "datastream_id": "weather42", + "result_time": "2026-01-01T00:00:00Z", + }) + out = codec.decode(wire) + # 23.5 and 60.0 are exact in binary32, so equality holds post round-trip. + assert out == _RESULT + for env_key in ("id", "datastream_id", "foi_id", "phenomenon_time", "result_time"): + assert env_key not in out + + +def test_decode_with_envelope_recovers_metadata(): + codec = SWEProtobufCodec(_weather_schema()) + wire = codec.encode(_RESULT, envelope={ + "id": "obs-1", + "datastream_id": "weather42", + "foi_id": "foi-9", + "phenomenon_time": "2026-01-01T00:00:00Z", + "result_time": "2026-01-01T00:00:01Z", + }) + full = codec.decode_with_envelope(wire) + assert full["result"] == _RESULT + assert full["id"] == "obs-1" + assert full["datastream@id"] == "weather42" + assert full["foi@id"] == "foi-9" + # Timestamps come back as ISO 8601 (RFC 3339) strings. + assert full["phenomenonTime"] == "2026-01-01T00:00:00Z" + assert full["resultTime"] == "2026-01-01T00:00:01Z" + + +def test_encode_ignores_envelope_keys_in_result_dict(): + """An envelope key accidentally present in the result dict is skipped, + not mis-encoded as a result field.""" + codec = SWEProtobufCodec(_weather_schema()) + polluted = dict(_RESULT, datastream_id="should-be-ignored") + wire = codec.encode(polluted) + # datastream_id was ignored (it's an envelope field, unset here). + assert codec.decode_with_envelope(wire)["datastream@id"] == "" + + +def test_encode_accepts_epoch_and_timeinstant_times(): + from oshconnect.timemanagement import TimeInstant + codec = SWEProtobufCodec(_weather_schema()) + wire = codec.encode(_RESULT, envelope={ + "phenomenon_time": 1_767_225_600, # 2026-01-01T00:00:00Z + "result_time": TimeInstant.from_string("2026-01-01T00:00:00Z"), + }) + full = codec.decode_with_envelope(wire) + assert full["phenomenonTime"] == "2026-01-01T00:00:00Z" + assert full["resultTime"].startswith("2026-01-01T00:00:00") + + +def test_unknown_result_field_raises_keyerror(): + codec = SWEProtobufCodec(_weather_schema()) + with pytest.raises(KeyError, match="not in message"): + codec.encode({"nonexistent": 1.0}) + + +def test_message_type_auto_detected_when_single(): + """message_type may be omitted when the descriptor set has one message.""" + codec = SWEProtobufCodec( + file_descriptor_set=_weather_descriptor_set(), message_type=None) + assert "air_temperature" in codec.result_field_names + + +def test_wrap_file_descriptor_proto_round_trips(): + """A bare FileDescriptorProto must be wrapped before use; the helper + does that and the wrapped form decodes identically.""" + from oshconnect.swe_protobuf import wrap_file_descriptor_proto + + # _weather_descriptor_set is a one-file Set; pull its single FDP back out + # to simulate a node that delivered a bare descriptor. + fds = descriptor_pb2.FileDescriptorSet() + fds.ParseFromString(_weather_descriptor_set()) + fdp_bytes = fds.file[0].SerializeToString() + + wrapped = wrap_file_descriptor_proto(fdp_bytes) + codec = SWEProtobufCodec( + file_descriptor_set=wrapped, + message_type="oshtest.weather.WeatherObservation") + assert codec.decode(codec.encode(_RESULT)) == _RESULT + + +def test_bare_descriptor_proto_without_wrap_raises(): + """Passing a bare FileDescriptorProto (unwrapped) fails loudly rather + than silently mis-parsing.""" + fds = descriptor_pb2.FileDescriptorSet() + fds.ParseFromString(_weather_descriptor_set()) + fdp_bytes = fds.file[0].SerializeToString() + with pytest.raises(Exception): # DecodeError or ValueError — never silent + SWEProtobufCodec(file_descriptor_set=fdp_bytes, + message_type="oshtest.weather.WeatherObservation") + + +def test_multi_file_set_resolves_non_google_dependency(): + """The realistic shape: a FileDescriptorSet where one file imports + another (non-google) file — like a per-datastream descriptor importing + swe_options. Exercises the topological add (set is ordered + dependent-first) and cross-file type resolution that single-file tests + never hit.""" + # File A — defines a type referenced by B. + fa = descriptor_pb2.FileDescriptorProto() + fa.name = "oshtest/units.proto" + fa.package = "oshtest" + fa.syntax = "proto3" + ua = fa.message_type.add() + ua.name = "Unit" + _add_field(ua, "code", 1, _FDP.TYPE_STRING) + + # File B — imports A and references oshtest.Unit in a result field. + fb = descriptor_pb2.FileDescriptorProto() + fb.name = "oshtest/obs2.proto" + fb.package = "oshtest" + fb.syntax = "proto3" + fb.dependency.append("oshtest/units.proto") + mb = fb.message_type.add() + mb.name = "Obs2" + _add_field(mb, "datastream_id", 2, _FDP.TYPE_STRING) + _add_field(mb, "air_temperature", 6, _FDP.TYPE_FLOAT) + _add_field(mb, "unit", 7, _FDP.TYPE_MESSAGE, ".oshtest.Unit") + + # Dependent (B) before dependency (A) — the loader must reorder. + fds = descriptor_pb2.FileDescriptorSet() + fds.file.append(fb) + fds.file.append(fa) + + # Constructs without error → topological add + non-google resolution work. + codec = SWEProtobufCodec( + file_descriptor_set=fds.SerializeToString(), message_type="oshtest.Obs2") + assert "air_temperature" in codec.result_field_names + assert "unit" in codec.result_field_names + # The nested cross-file message decodes as a nested dict (default here, + # since only the scalar was set on encode). + out = codec.decode(codec.encode({"air_temperature": 1.5})) + assert out == {"air_temperature": 1.5, "unit": {"code": ""}} + + +def test_missing_non_google_dependency_raises(): + """A descriptor importing a non-google file that isn't in the set must + fail loudly — that's the FileDescriptorSet completeness contract.""" + fdp = descriptor_pb2.FileDescriptorProto() + fdp.name = "oshtest/needs_dep.proto" + fdp.package = "oshtest" + fdp.syntax = "proto3" + fdp.dependency.append("oshtest/missing.proto") + m = fdp.message_type.add() + m.name = "Thing" + _add_field(m, "x", 1, _FDP.TYPE_FLOAT) + fds = descriptor_pb2.FileDescriptorSet() + fds.file.append(fdp) + with pytest.raises(ImportError, match="missing dependencies"): + SWEProtobufCodec(file_descriptor_set=fds.SerializeToString(), + message_type="oshtest.Thing") + + +# --------------------------------------------------------------------------- +# Wiring through Datastream and the format picker +# --------------------------------------------------------------------------- + + +def test_datastream_insert_routes_through_protobuf_codec(): + """Datastream.insert(...) → _encode_for_wire picks the proto codec and + supplies datastream_id/result_time as envelope; decode_observation + returns just the result record.""" + 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": "weather42", + "name": "proto", + "validTime": ["2026-01-01T00:00:00Z", "2099-01-01T00:00:00Z"], + "schema": _weather_schema().to_sweproto_dict(), + "formats": ["application/swe+proto"], } - 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' + 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) + ds.insert(_RESULT) + assert len(captured) == 1 + # decode_observation returns the result record (envelope stripped). + assert ds.decode_observation(captured[0]) == _RESULT -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'), + +# --------------------------------------------------------------------------- +# Schema generation — translating a SWE record into a swe+proto descriptor +# --------------------------------------------------------------------------- + + +def _swe_record(): + """A scalar SWE DataRecord covering every translatable component type.""" + from oshconnect.api_utils import UCUMCode, URI + from oshconnect.swe_components import ( + BooleanSchema, CategorySchema, CountSchema, DataRecordSchema, + QuantitySchema, TextSchema, TimeSchema, + ) + 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="Temp", definition="http://example.org/temp", + uom=UCUMCode(code="Cel", label="Celsius")), + CountSchema(name="samples", label="Samples", definition="http://example.org/n", + uom=UCUMCode(code="1", label="count")), + BooleanSchema(name="clear_sky", label="Clear", definition="http://example.org/clear"), + CategorySchema(name="state", label="State", definition="http://example.org/state"), + TextSchema(name="note", label="Note", definition="http://example.org/note"), ], ) - codec = SWEProtobufCodec(SWEProtobufDatastreamRecordSchema(record_schema=rec)) - assert codec.decode(codec.encode({'state': 'on'}))['state'] == 'on' -# --------------------------------------------------------------------------- -# Composite types -# --------------------------------------------------------------------------- +def test_from_record_schema_generates_round_trippable_descriptor(): + """A swe+proto schema generated from a SWE DataRecord must encode and + decode through the codec — proving the create-side descriptor matches + what the codec reads.""" + schema = SWEProtobufDatastreamRecordSchema.from_record_schema( + _swe_record(), message_name="Observation_ds42") + assert schema.message_type == "oshconnect.sweproto.Observation_ds42" + codec = SWEProtobufCodec(schema) + assert codec.result_field_names == [ + "time", "temp", "samples", "clear_sky", "state", "note"] + value = { + "time": "2026-01-01T00:00:00Z", + "temp": 23.5, + "samples": 42, + "clear_sky": True, + "state": "on", + "note": "sunny", + } + assert codec.decode(codec.encode(value)) == value -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.""" +def test_from_other_schema_translates_swe_binary_schema(): + """Translating from a SWE+binary datastream schema reuses its semantic + record_schema and yields a working swe+proto schema.""" + from oshconnect import BinaryEncoding, SWEBinaryDatastreamRecordSchema + + binary = SWEBinaryDatastreamRecordSchema( + record_schema=_swe_record(), + record_encoding=BinaryEncoding(members=[]), + ) + proto = SWEProtobufDatastreamRecordSchema.from_other_schema(binary) + codec = SWEProtobufCodec(proto) + assert "temp" in codec.result_field_names + out = codec.decode(codec.encode({ + "time": "2026-01-01T00:00:00Z", "temp": 1.0, "samples": 1, + "clear_sky": False, "state": "off", "note": "x", + })) + assert out["temp"] == 1.0 and out["state"] == "off" + + +def test_nested_record_generates_and_round_trips(): + """Nested DataRecords are first-class: the generator emits a nested + message and the codec recurses into a nested dict, end to end.""" + from oshconnect.api_utils import UCUMCode, URI + from oshconnect.swe_components import ( + DataRecordSchema, QuantitySchema, TimeSchema, + ) inner = DataRecordSchema( - name='inner', label='Inner', - definition='http://example.org/inner', + name="location", label="Location", definition="http://example.org/loc", 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')), + 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', + name="obs", label="Obs", definition="http://example.org/obs", 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')), + 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="Temp", definition="http://example.org/temp", + uom=UCUMCode(code="Cel", label="C")), inner, ], ) - codec = SWEProtobufCodec(SWEProtobufDatastreamRecordSchema(record_schema=outer)) - value = {'time': '2026-05-19T00:00:00Z', - 'inner': {'lat': 12.5, 'lon': -42.0}} + codec = SWEProtobufCodec( + SWEProtobufDatastreamRecordSchema.from_record_schema(outer)) + assert codec.result_field_names == ["time", "temp", "location"] + value = { + "time": "2026-01-01T00:00:00Z", + "temp": 21.0, + "location": {"lat": 38.9, "lon": -77.0}, + } assert codec.decode(codec.encode(value)) == value -def test_vector_round_trip(): - schema = DataRecordSchema( - name='r', fields=[ +def test_deeply_nested_records_round_trip(): + """Recursion is arbitrary-depth — a record inside a record inside a record.""" + from oshconnect.api_utils import UCUMCode + from oshconnect.swe_components import DataRecordSchema, QuantitySchema + + def q(name): + return QuantitySchema(name=name, label=name, + definition=f"http://example.org/{name}", + uom=UCUMCode(code="m", label="m")) + + level3 = DataRecordSchema(name="c", definition="http://example.org/c", fields=[q("z")]) + level2 = DataRecordSchema(name="b", definition="http://example.org/b", fields=[q("y"), level3]) + level1 = DataRecordSchema(name="a", definition="http://example.org/a", fields=[q("x"), level2]) + codec = SWEProtobufCodec( + SWEProtobufDatastreamRecordSchema.from_record_schema(level1)) + value = {"x": 1.0, "b": {"y": 2.0, "c": {"z": 3.0}}} + assert codec.decode(codec.encode(value)) == value + + +def test_vector_generates_and_round_trips(): + """Vectors become a nested message (Vec); encode accepts a sequence, + decode returns a dict keyed by coordinate name.""" + from oshconnect.api_utils import UCUMCode + from oshconnect.swe_components import ( + DataRecordSchema, QuantitySchema, VectorSchema, + ) + rec = DataRecordSchema( + name="r", definition="http://example.org/r", + fields=[ VectorSchema( - name='pos', label='Position', - definition='http://example.org/pos', - reference_frame='http://example.org/frame', + 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')), + 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 + codec = SWEProtobufCodec( + SWEProtobufDatastreamRecordSchema.from_record_schema(rec)) + # Encode the vector as a sequence... + wire = codec.encode({"pos": [1.0, 2.0, 3.0]}) + # ...decode returns it keyed by coordinate name. + assert codec.decode(wire) == {"pos": {"x": 1.0, "y": 2.0, "z": 3.0}} + # A mapping value encodes identically. + assert codec.encode({"pos": {"x": 1.0, "y": 2.0, "z": 3.0}}) == wire -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. +_OGC_DT = "http://www.opengis.net/def/dataType/OGC/0/" - 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. - """ + +def _field_type(codec, name): + """The proto wire type of a top-level field on the codec's message.""" + return codec._descriptor.fields_by_name[name].type + + +def test_datatype_drives_leaf_proto_type(): + """A component's OGC dataType selects the proto wire type (float32 → + float, signedLong → int64), matching the node's getDataType — not a + hardcoded double/int32.""" + from google.protobuf.descriptor import FieldDescriptor + from oshconnect.api_utils import UCUMCode + from oshconnect.swe_components import ( + CountSchema, DataRecordSchema, QuantitySchema, + ) 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')), - ), + name="r", definition="http://example.org/r", + fields=[ + QuantitySchema(name="temp", label="T", definition="http://example.org/t", + uom=UCUMCode(code="Cel", label="C")), + CountSchema(name="ticks", label="N", definition="http://example.org/n", + uom=UCUMCode(code="1", label="c")), ], ) - codec = SWEProtobufCodec(SWEProtobufDatastreamRecordSchema(record_schema=rec)) - value = {'samples': [1.0, 2.0, 3.0]} - assert codec.decode(codec.encode(value)) == value + schema = SWEProtobufDatastreamRecordSchema.from_record_schema( + rec, datatype_by_path={ + "/temp": _OGC_DT + "float32", + "/ticks": _OGC_DT + "signedLong", + }) + codec = SWEProtobufCodec(schema) + assert _field_type(codec, "temp") == FieldDescriptor.TYPE_FLOAT + assert _field_type(codec, "ticks") == FieldDescriptor.TYPE_INT64 + # float32-exact value round-trips. + assert codec.decode(codec.encode({"temp": 1.5, "ticks": 9_000_000_000})) == { + "temp": 1.5, "ticks": 9_000_000_000} + + +def test_default_leaf_types_match_node_defaults(): + """With no dataType, Quantity → double and Count → int32 — the node's + own defaults.""" + from google.protobuf.descriptor import FieldDescriptor + codec = SWEProtobufCodec( + SWEProtobufDatastreamRecordSchema.from_record_schema(_swe_record())) + assert _field_type(codec, "temp") == FieldDescriptor.TYPE_DOUBLE + assert _field_type(codec, "samples") == FieldDescriptor.TYPE_INT32 + + +def test_numeric_time_maps_to_double_iso_time_to_timestamp(): + from google.protobuf.descriptor import FieldDescriptor + from oshconnect.api_utils import UCUMCode, URI + from oshconnect.swe_components import DataRecordSchema, TimeSchema + rec = DataRecordSchema( + name="r", definition="http://example.org/r", + fields=[ + TimeSchema(name="iso_t", label="iso", + definition="http://www.opengis.net/def/property/OGC/0/SamplingTime", + uom=URI(href="http://www.opengis.net/def/uom/ISO-8601/0/Gregorian")), + TimeSchema(name="epoch_t", label="epoch", + definition="http://www.opengis.net/def/property/OGC/0/SamplingTime", + uom=UCUMCode(code="s", label="seconds")), + ], + ) + codec = SWEProtobufCodec( + SWEProtobufDatastreamRecordSchema.from_record_schema(rec)) + # ISO time → Timestamp (message), numeric time → double. + assert _field_type(codec, "iso_t") == FieldDescriptor.TYPE_MESSAGE + assert _field_type(codec, "epoch_t") == FieldDescriptor.TYPE_DOUBLE + out = codec.decode(codec.encode({"iso_t": "2026-01-01T00:00:00Z", "epoch_t": 1767225600.0})) + assert out["iso_t"] == "2026-01-01T00:00:00Z" + assert out["epoch_t"] == 1767225600.0 + + +def test_from_other_schema_mines_binary_member_datatypes(): + """Translating from SWE+Binary uses the encoding members' dataTypes — + including for nested paths like /pos/x — so float32 stays float32.""" + from google.protobuf.descriptor import FieldDescriptor + from oshconnect import ( + BinaryComponentMember, BinaryEncoding, SWEBinaryDatastreamRecordSchema, + ) + from oshconnect.api_utils import UCUMCode + from oshconnect.swe_components import ( + DataRecordSchema, QuantitySchema, VectorSchema, + ) + rec = DataRecordSchema( + name="r", definition="http://example.org/r", + fields=[ + QuantitySchema(name="temp", label="T", definition="http://example.org/t", + uom=UCUMCode(code="Cel", label="C")), + VectorSchema( + name="pos", label="P", definition="http://example.org/p", + reference_frame="http://example.org/f", + 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")), + ]), + ], + ) + binary = SWEBinaryDatastreamRecordSchema( + record_schema=rec, + record_encoding=BinaryEncoding(members=[ + BinaryComponentMember(ref="/temp", dataType=_OGC_DT + "float32"), + BinaryComponentMember(ref="/pos/x", dataType=_OGC_DT + "float32"), + BinaryComponentMember(ref="/pos/y", dataType=_OGC_DT + "float32"), + ]), + ) + codec = SWEProtobufCodec( + SWEProtobufDatastreamRecordSchema.from_other_schema(binary)) + assert _field_type(codec, "temp") == FieldDescriptor.TYPE_FLOAT + # nested vector coordinate types resolved via /pos/x, /pos/y + pos_desc = codec._descriptor.fields_by_name["pos"].message_type + assert pos_desc.fields_by_name["x"].type == FieldDescriptor.TYPE_FLOAT + assert codec.decode(codec.encode({"temp": 1.5, "pos": [2.5, 3.5]})) == { + "temp": 1.5, "pos": {"x": 2.5, "y": 3.5}} + + +def test_constrained_category_generates_enum_and_round_trips(): + """A Category with an AllowedTokens constraint becomes a proto enum; + encode accepts the token string, decode returns it.""" + from google.protobuf.descriptor import FieldDescriptor + from oshconnect.swe_components import CategorySchema, DataRecordSchema + + rec = DataRecordSchema( + name="r", definition="http://example.org/r", + fields=[ + CategorySchema(name="sky", label="Sky", definition="http://example.org/sky", + constraint={"values": ["CLEAR", "CLOUDY", "RAIN"]}), + ], + ) + codec = SWEProtobufCodec( + SWEProtobufDatastreamRecordSchema.from_record_schema(rec)) + assert _field_type(codec, "sky") == FieldDescriptor.TYPE_ENUM + # round-trips on the token string, not the integer ordinal + assert codec.decode(codec.encode({"sky": "CLOUDY"})) == {"sky": "CLOUDY"} + assert codec.decode(codec.encode({"sky": "RAIN"})) == {"sky": "RAIN"} -def test_data_array_of_counts_round_trip(): - """Default dataType for Count is signedInt (4 bytes BE), matching OSH.""" +def test_unconstrained_category_stays_string(): + from google.protobuf.descriptor import FieldDescriptor + from oshconnect.swe_components import CategorySchema, DataRecordSchema 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')), - ), + name="r", definition="http://example.org/r", + fields=[ + CategorySchema(name="label", label="L", definition="http://example.org/l"), ], ) - codec = SWEProtobufCodec(SWEProtobufDatastreamRecordSchema(record_schema=rec)) - value = {'ids': [7, 11, 13, 17]} - assert codec.decode(codec.encode(value)) == value + codec = SWEProtobufCodec( + SWEProtobufDatastreamRecordSchema.from_record_schema(rec)) + assert _field_type(codec, "label") == FieldDescriptor.TYPE_STRING + assert codec.decode(codec.encode({"label": "anything"})) == {"label": "anything"} -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.""" +def test_enum_rejects_unknown_token(): + from oshconnect.swe_components import CategorySchema, DataRecordSchema + rec = DataRecordSchema( + name="r", definition="http://example.org/r", + fields=[ + CategorySchema(name="sky", definition="http://example.org/sky", + constraint={"values": ["CLEAR", "CLOUDY"]}), + ], + ) + codec = SWEProtobufCodec( + SWEProtobufDatastreamRecordSchema.from_record_schema(rec)) + with pytest.raises(KeyError, match="not an allowed token"): + codec.encode({"sky": "SNOW"}) + + +def test_enum_inside_nested_record_round_trips(): + """An enum nested inside a record is scoped to that message and still + round-trips — exercises the per-message enum nesting.""" + from oshconnect.api_utils import UCUMCode + from oshconnect.swe_components import ( + CategorySchema, DataRecordSchema, QuantitySchema, + ) inner = DataRecordSchema( - name='inner', label='Inner', - definition='http://example.org/inner', + name="status", definition="http://example.org/status", fields=[ - QuantitySchema(name='x', label='X', - definition='http://example.org/x', - uom=UCUMCode(code='m', label='m')), + QuantitySchema(name="battery", label="B", definition="http://example.org/b", + uom=UCUMCode(code="%", label="pct")), + CategorySchema(name="mode", definition="http://example.org/mode", + constraint={"values": ["IDLE", "ACTIVE"]}), + ], + ) + outer = DataRecordSchema( + name="obs", definition="http://example.org/obs", + fields=[ + CategorySchema(name="sky", definition="http://example.org/sky", + constraint={"values": ["CLEAR", "RAIN"]}), + inner, ], ) + codec = SWEProtobufCodec( + SWEProtobufDatastreamRecordSchema.from_record_schema(outer)) + value = {"sky": "RAIN", "status": {"battery": 88, "mode": "ACTIVE"}} + assert codec.decode(codec.encode(value)) == value + + +def test_enum_rejects_non_identifier_token(): + """Tokens must be valid proto enum identifiers (as the node requires).""" + from oshconnect.swe_components import CategorySchema, DataRecordSchema rec = DataRecordSchema( - name='r', fields=[ - DataArraySchema( - name='samples', label='Samples', - definition='http://example.org/samples', - element_count={'value': 2}, - element_type=inner, - ), + name="r", definition="http://example.org/r", + fields=[ + CategorySchema(name="sky", definition="http://example.org/sky", + constraint={"values": ["clear sky", "rain"]}), ], ) - codec = SWEProtobufCodec(SWEProtobufDatastreamRecordSchema(record_schema=rec)) - with pytest.raises(TypeError, match="DataArray.element_type"): - codec.encode({'samples': [{'x': 1.0}, {'x': 2.0}]}) + with pytest.raises(ValueError, match="valid proto enum identifier"): + SWEProtobufDatastreamRecordSchema.from_record_schema(rec) -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_data_array_of_scalars_generates_wrapper_and_round_trips(): + """A DataArray becomes the node's `Array { repeated = 1 }` + wrapper, round-tripping as {array: {element: [...]}}.""" + from google.protobuf.descriptor import FieldDescriptor + from oshconnect.api_utils import UCUMCode + from oshconnect.swe_components import ( + DataArraySchema, DataRecordSchema, QuantitySchema, + ) + rec = DataRecordSchema( + name="r", definition="http://example.org/r", + fields=[ + DataArraySchema( + name="samples", label="Samples", definition="http://example.org/s", + element_count={"value": 3}, + element_type=QuantitySchema( + name="reading", label="R", definition="http://example.org/x", + uom=UCUMCode(code="m", label="m")), + ), + ], + ) + codec = SWEProtobufCodec( + SWEProtobufDatastreamRecordSchema.from_record_schema(rec)) + # `samples` is a singular message field (the Array wrapper); the repeated + # element lives inside it. + samples_field = codec._descriptor.fields_by_name["samples"] + assert samples_field.type == FieldDescriptor.TYPE_MESSAGE + reading_field = samples_field.message_type.fields_by_name["reading"] + assert reading_field.is_repeated + value = {"samples": {"reading": [1.0, 2.0, 3.0]}} + assert codec.decode(codec.encode(value)) == value -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 +def test_data_array_of_records_round_trips(): + """Array of records exercises repeated + nested-message together.""" + from oshconnect.api_utils import UCUMCode + from oshconnect.swe_components import ( + DataArraySchema, DataRecordSchema, QuantitySchema, + ) + point = DataRecordSchema( + name="point", definition="http://example.org/pt", + fields=[ + QuantitySchema(name="lat", label="la", definition="http://example.org/lat", + uom=UCUMCode(code="deg", label="d")), + QuantitySchema(name="lon", label="lo", definition="http://example.org/lon", + uom=UCUMCode(code="deg", label="d")), + ], + ) + rec = DataRecordSchema( + name="r", definition="http://example.org/r", + fields=[ + DataArraySchema( + name="track", label="Track", definition="http://example.org/track", + element_count={"value": 2}, element_type=point), + ], + ) + codec = SWEProtobufCodec( + SWEProtobufDatastreamRecordSchema.from_record_schema(rec)) + value = {"track": {"point": [ + {"lat": 1.0, "lon": 2.0}, {"lat": 3.0, "lon": 4.0}]}} + assert codec.decode(codec.encode(value)) == value # --------------------------------------------------------------------------- -# Wiring through Datastream +# .proto source translation (render + recompile) # --------------------------------------------------------------------------- -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 +def _sky_temp_schema(message_name="Obs_ds1"): + from oshconnect.api_utils import UCUMCode + from oshconnect.swe_components import ( + CategorySchema, DataRecordSchema, QuantitySchema, + ) + rec = DataRecordSchema( + name="obs", definition="http://example.org/o", + fields=[ + QuantitySchema(name="temp", label="T", definition="http://example.org/t", + uom=UCUMCode(code="Cel", label="C")), + CategorySchema(name="sky", definition="http://example.org/sky", + constraint={"values": ["CLEAR", "RAIN"]}), + ], + ) + return SWEProtobufDatastreamRecordSchema.from_record_schema( + rec, message_name=message_name) + + +def test_to_proto_source_renders_editable_text(): + """to_proto_source renders the carried descriptor as .proto text — no + protoc needed, and faithful to the descriptor (envelope, scalars, enum).""" + text = _sky_temp_schema().to_proto_source() + assert 'syntax = "proto3";' in text + assert "package oshconnect.sweproto;" in text + assert 'import "google/protobuf/timestamp.proto";' in text + assert "message Obs_ds1 {" in text + # envelope occupies 1–5, result fields start at 6 + assert "double temp = 6;" in text + assert "enum Enum_sky {" in text + assert "CLEAR = 0;" in text + assert "sky = 7;" in text + + +@pytest.mark.skipif(not _HAS_PROTOC, reason="protoc not installed") +def test_proto_source_round_trips_via_protoc(): + """to_proto_source → from_proto_source (protoc) → codec still round-trips.""" + text = _sky_temp_schema().to_proto_source() + schema = SWEProtobufDatastreamRecordSchema.from_proto_source(text) + assert schema.message_type == "oshconnect.sweproto.Obs_ds1" + codec = SWEProtobufCodec(schema) + assert codec.decode(codec.encode({"temp": 1.5, "sky": "RAIN"})) == { + "temp": 1.5, "sky": "RAIN"} + + +@pytest.mark.skipif(not _HAS_PROTOC, reason="protoc not installed") +def test_modified_proto_source_takes_effect(): + """The point of the .proto translation: hand-edit the text and the change + flows through. Add a field, recompile, confirm it's live.""" + text = _sky_temp_schema().to_proto_source() + edited = text.replace( + " double temp = 6;", + " double temp = 6;\n float humidity = 8;") + schema = SWEProtobufDatastreamRecordSchema.from_proto_source(edited) + codec = SWEProtobufCodec(schema) + assert "humidity" in codec.result_field_names + assert codec.decode(codec.encode({"temp": 1.0, "humidity": 2.5, "sky": "CLEAR"})) == { + "temp": 1.0, "humidity": 2.5, "sky": "CLEAR"} - 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) +def test_from_record_schema_rejects_unsupported_composite(): + """Composites without a wire mapping yet (DataChoice, etc.) still fail + loudly rather than emit a wrong descriptor.""" + from oshconnect.api_utils import UCUMCode + from oshconnect.swe_components import ( + DataChoiceSchema, DataRecordSchema, QuantitySchema, + ) + rec = DataRecordSchema( + name="r", fields=[ + DataChoiceSchema( + name="choice", label="Choice", definition="http://example.org/c", + items=[ + QuantitySchema(name="a", label="A", definition="http://example.org/a", + uom=UCUMCode(code="m", label="m")), + ], + ), + ], + ) + with pytest.raises(NotImplementedError): + SWEProtobufDatastreamRecordSchema.from_record_schema(rec) - 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_generated_field_name_is_sanitized(): + from oshconnect.swe_components import DataRecordSchema, QuantitySchema + from oshconnect.api_utils import UCUMCode + # SWE NameTokens allow hyphens (^[A-Za-z][A-Za-z0-9_\\-]*$) — invalid in + # proto field identifiers, so they sanitize to underscores. + rec = DataRecordSchema( + name="r", fields=[ + QuantitySchema(name="air-temp-2m", label="T", + definition="http://example.org/t", + uom=UCUMCode(code="Cel", label="C")), + ], + ) + codec = SWEProtobufCodec( + SWEProtobufDatastreamRecordSchema.from_record_schema(rec)) + assert codec.result_field_names == ["air_temp_2m"] def test_pick_schema_format_picks_protobuf_when_present(): @@ -379,12 +899,10 @@ def test_pick_schema_format_picks_protobuf_when_present(): 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 + from oshconnect.resources.system import System 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 + assert parser.__func__ is SWEDatastreamRecordSchema.from_swejson_dict.__func__ From 2fa47603019d0dd28625135d178ee6042485de46 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Wed, 1 Jul 2026 14:51:29 -0500 Subject: [PATCH 31/33] feat: add NATS.io transport and real swe+flatbuffers codec Add a NATS.io pub/sub transport as a drop-in twin of the MQTT path, targeting the osh-nats-dev consys-nats server, plus a working FlexBuffers-based swe+flatbuffers codec. NATS transport: - NatsCommClient (csapi4py/nats.py): synchronous interface matching MQTTCommClient, backed by an asyncio loop on a background thread; qos/retain accepted-and-ignored for parity; clean shutdown. - Node(enable_nats, nats_port, nats_token) mirrors enable_mqtt; resources select the active transport via get_comm_client(). - Nested-under-systems subjects (StreamableResource.get_nats_subject), matching ConSysApiNatsConnector.getResourceUri exactly (verified live). - PROACTIVE format handling: PULL subscribes to a format wildcard (:data.*), reads the concrete format per-message from the delivered subject (nats_content_type_from_subject), and decodes via decode_observation(raw, obs_format=...). Publish keeps the exact format. - nats-py added as a core dependency. swe+flatbuffers: - OSH's wire format is schemaless length-prefixed FlexBuffers, not compiled FlatBuffers, so the flatc vector-of-union limitation never applied. SWEFlatBuffersCodec now encodes/decodes real frames. - Fix: swe-flatbuffers was missing from MQTT_TOPIC_FORMAT_TOKENS. Tests: test_nats_subjects.py (subjects, wildcard, format extraction, transport dispatch), rewritten test_swe_flatbuffers.py for the real codec. Docs: tutorial, API ref, spec-deviations #3 (resolved) and #5. --- docs/source/api.rst | 8 + docs/source/tutorial.rst | 24 +- pyproject.toml | 3 + src/oshconnect/csapi4py/mqtt.py | 1 + src/oshconnect/csapi4py/nats.py | 384 ++++++++++++++++++++++ src/oshconnect/node.py | 31 +- src/oshconnect/resources/base.py | 129 +++++++- src/oshconnect/resources/controlstream.py | 42 ++- src/oshconnect/resources/datastream.py | 47 ++- src/oshconnect/swe_flatbuffers.py | 104 +++--- tests/test_mqtt_topics.py | 4 + tests/test_nats_subjects.py | 240 ++++++++++++++ tests/test_swe_binary.py | 3 + tests/test_swe_flatbuffers.py | 54 +-- tests/test_swe_protobuf.py | 1 + uv.lock | 11 + 16 files changed, 1003 insertions(+), 83 deletions(-) create mode 100644 src/oshconnect/csapi4py/nats.py create mode 100644 tests/test_nats_subjects.py diff --git a/docs/source/api.rst b/docs/source/api.rst index 09ae411..19e73f2 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -117,6 +117,14 @@ MQTT Client ~~~~~~~~~~~ .. automodule:: oshconnect.csapi4py.mqtt + :members: + :undoc-members: + :show-inheritance: + +NATS Client +~~~~~~~~~~~ + +.. automodule:: oshconnect.csapi4py.nats :members: :undoc-members: :show-inheritance: \ No newline at end of file diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index f6591c3..d1a7a0d 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -61,6 +61,26 @@ To connect a node with MQTT support for streaming: enable_mqtt=True, mqtt_port=1883) app.add_node(node) +To stream over **NATS.io** instead (the corporate-bus transport, served by +OSH's ``sensorhub-service-consys-nats`` binding), enable it the same way — +``enable_nats`` mirrors ``enable_mqtt``: + +.. code-block:: python + + node = Node(protocol='http', address='localhost', port=8585, + username='test', password='test', + enable_nats=True, nats_port=4222) # or nats_token='...' + app.add_node(node) + +The two transports are drop-in twins: `Datastream` / `ControlStream` drive +whichever one the node was configured with (NATS takes precedence if both +are enabled). The only wire difference is the subject namespace — NATS data +subjects are dot-delimited and *nested under systems* +(``api.systems.{sysId}.datastreams.{dsId}.observations:data.``), +which the client builds for you. Only PROACTIVE-mode plain publish/subscribe +is supported; the optional flow-control channel and JetStream are out of +scope. + Authentication -------------- @@ -79,7 +99,9 @@ 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``. +also flow into the MQTT client when ``enable_mqtt=True`` (and, for NATS, +into user/password auth when ``enable_nats=True``; pass ``nats_token`` for +token auth instead). For an unsecured server (e.g., a local OSH dev instance), simply omit ``username`` and ``password``: diff --git a/pyproject.toml b/pyproject.toml index a4acd9a..82d5e7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,9 @@ authors = [ requires-python = "<4.0,>=3.12" dependencies = [ "paho-mqtt>=2.1.0", + # NATS.io transport for the CS API Part 3 Pub/Sub binding (NatsCommClient), + # the corporate-bus twin of the paho-mqtt transport. + "nats-py>=2.6.0", "pydantic>=2.13.4,<3.0.0", "shapely>=2.1.2,<3.0.0", # websockets 16.0 is several majors past the previous floor; OSHConnect diff --git a/src/oshconnect/csapi4py/mqtt.py b/src/oshconnect/csapi4py/mqtt.py index 1b42e98..f375dce 100644 --- a/src/oshconnect/csapi4py/mqtt.py +++ b/src/oshconnect/csapi4py/mqtt.py @@ -18,6 +18,7 @@ "application/swe+binary": "swe-binary", "application/swe+csv": "swe-csv", "application/swe+proto": "swe-proto", + "application/swe+flatbuffers": "swe-flatbuffers", "application/om+json": "om-json", "application/sml+json": "sml-json", } diff --git a/src/oshconnect/csapi4py/nats.py b/src/oshconnect/csapi4py/nats.py new file mode 100644 index 0000000..4db8c0d --- /dev/null +++ b/src/oshconnect/csapi4py/nats.py @@ -0,0 +1,384 @@ +# ============================================================================= +# Copyright (c) 2026 Georobotix Innovative Research +# Author: Ian Patterson +# Contact Email: ian.patterson@georobotix.us +# ============================================================================= + +"""NATS.io transport for the CS API Part 3 Pub/Sub binding. + +:class:`NatsCommClient` is a drop-in twin of +:class:`oshconnect.csapi4py.mqtt.MQTTCommClient`: it exposes the same +synchronous ``connect``/``start``/``subscribe``/``publish``/``stop`` +interface (including the paho-style ``msg_callback(client, userdata, msg)`` +signature, where ``msg.topic`` and ``msg.payload`` are read) so that +:class:`~oshconnect.resources.base.StreamableResource` can drive either +transport without change. + +Internally the client owns an :mod:`asyncio` event loop running on a +background thread; the ``nats-py`` client is asyncio-native, so each +synchronous method marshals its work onto that loop via +:func:`asyncio.run_coroutine_threadsafe`. Callbacks fire on the loop +thread — the same "not the main thread" contract paho already imposes. + +**Subject conventions.** The OSH NATS binding (``sensorhub-service-consys-nats``) +maps a subject to a REST resource path by stripping the ``{nodeId}`` prefix +and the ``:data`` suffix and replacing ``.`` with ``/`` (see +``ConSysApiNatsConnector.getResourceUri``). Data subjects are therefore the +canonical *nested-under-systems* resource path, dot-delimited, e.g.:: + + api.systems.{sysId}.datastreams.{dsId}.observations:data.swe-proto + +This differs from the client's flat MQTT topics — the nesting (and the +parent system id it requires) is built at the resource layer in +:meth:`StreamableResource.get_nats_subject`. This module only handles the +delimiter mapping (:func:`nats_subject_from_topic`) as a safety net for +callers that hand it an MQTT-style topic string. + +Only PROACTIVE-mode plain publish/subscribe is implemented (matching the +server's default ``dataStreamingMode``); the optional ``_control.*`` +request/reply flow-control channel and JetStream durable consumers are out +of scope for this transport twin. +""" +from __future__ import annotations + +import asyncio +import logging +import threading + +from .mqtt import MQTT_TOPIC_FORMAT_TOKENS + +try: + import nats as _nats +except ImportError: # pragma: no cover - exercised only without the extra + _nats = None + +logger = logging.getLogger(__name__) + +# Reverse of ``MQTT_TOPIC_FORMAT_TOKENS``: ``:data.`` subtopic → MIME. +_TOKEN_TO_CONTENT_TYPE = {token: ct for ct, token in MQTT_TOPIC_FORMAT_TOKENS.items()} + + +def nats_content_type_from_subject(subject: str) -> str | None: + """Return the MIME content-type named by a data subject's format subtopic. + + Parses the trailing ``:data.`` of a NATS data subject and maps the + token back to its MIME type (e.g. ``…observations:data.swe-flatbuffers`` → + ``application/swe+flatbuffers``). Returns ``None`` for a bare ``:data`` + subject (server-default format), an unknown token, or a subject with no + ``:data`` suffix. Used to decode messages received via a format-wildcard + subscription, where the concrete format is only known per-message. + """ + idx = subject.rfind(':data') + if idx == -1: + return None + tail = subject[idx + len(':data'):] + if not tail.startswith('.'): + return None + token = tail[1:] + return _TOKEN_TO_CONTENT_TYPE.get(token) + + +def nats_subject_from_topic(topic: str) -> str: + """Translate an MQTT-style topic into a NATS subject. + + NATS uses ``.`` as the token separator, ``*`` as the single-token + wildcard and ``>`` as the multi-token (tail) wildcard, where MQTT uses + ``/``, ``+`` and ``#`` respectively. A subject that is already in NATS + form (no ``/``, ``+`` or ``#``) passes through unchanged, so this is + safe to call on subjects built directly by + :meth:`StreamableResource.get_nats_subject`. + """ + return topic.replace('/', '.').replace('+', '*').replace('#', '>') + + +class _NatsMsgShim: + """Adapt a ``nats.aio.msg.Msg`` to the paho-style object the resource + callbacks expect. + + Exposes ``topic`` (the NATS subject, left dot-delimited so it compares + equal to the dot-delimited ``self._topic`` the resource subscribed + with) and ``payload`` (raw ``bytes``). ``headers`` carries the NATS + message headers (e.g. ``CS-Origin``) for callers that need them. + """ + + __slots__ = ("topic", "payload", "headers") + + def __init__(self, subject: str, payload: bytes, headers=None): + self.topic = subject + self.payload = payload + self.headers = headers + + +class NatsCommClient: + """Synchronous, thread-backed NATS client mirroring ``MQTTCommClient``. + + :param url: NATS server host (or full ``nats://host:port`` URL). + :param port: server port, default ``4222`` (ignored when ``url`` already + carries a scheme/port). + :param username: optional user for user/password auth. + :param password: optional password for user/password auth. + :param token: optional auth token (takes precedence over user/password, + matching the Java ``ConSysApiNatsService`` precedence). + :param client_id_suffix: appended to the connection name for traceability. + :param connect_timeout: seconds to wait for the initial connect. + :param max_reconnects: reconnect attempts (``-1`` = unlimited, matching + the server default). + :raises RuntimeError: if the ``nats-py`` package is not importable. + """ + + def __init__(self, url, port=4222, username=None, password=None, token=None, + client_id_suffix="", connect_timeout=5, max_reconnects=-1): + if _nats is None: + raise RuntimeError( + "NATS transport requires the 'nats-py' package. It is a core " + "dependency of oshconnect; reinstall the package if this import " + "failed." + ) + self.__url = url if "://" in str(url) else f"nats://{url}:{port}" + self.__port = port + self.__username = username + self.__password = password + self.__token = token + self.__name = f"oscapy_nats-{client_id_suffix}" + self.__connect_timeout = connect_timeout + self.__max_reconnects = max_reconnects + + self.__nc = None + self.__subs = {} + self.__is_connected = False + self.__closing = False + + # asyncio loop pinned to a dedicated background thread. nats-py binds + # to the running loop, so every coroutine must be scheduled onto it. + self.__loop = asyncio.new_event_loop() + self.__thread = threading.Thread( + target=self.__run_loop, name=self.__name, daemon=True) + self.__thread.start() + + # Callback slots kept for MQTTCommClient interface parity. NATS has no + # broker-side subscribe/publish acknowledgement callbacks, so most are + # stored-and-ignored; on_connect/on_disconnect are invoked on the + # corresponding lifecycle transitions. + self.__on_connect = None + self.__on_disconnect = None + self.__on_subscribe = None + self.__on_unsubscribe = None + self.__on_publish = None + self.__on_message = None + self.__on_log = None + + # -- event-loop plumbing -------------------------------------------------- + + def __run_loop(self): + asyncio.set_event_loop(self.__loop) + self.__loop.run_forever() + + def __call_sync(self, coro, timeout=None): + """Schedule *coro* on the loop thread and block for its result.""" + future = asyncio.run_coroutine_threadsafe(coro, self.__loop) + return future.result(timeout=timeout) + + # -- lifecycle ------------------------------------------------------------ + + def connect(self, keepalive=60): + """Open the connection to the NATS server (blocks until connected). + + ``keepalive`` is accepted for MQTTCommClient signature parity and is + unused — nats-py manages ping/pong keepalive internally. + """ + logger.info('NATS connecting to %s', self.__url) + try: + self.__call_sync(self.__connect(), timeout=self.__connect_timeout + 2) + except Exception as exc: + logger.error('NATS connect failed: %s', exc) + raise + + async def __connect(self): + opts = dict( + servers=[self.__url], + name=self.__name, + connect_timeout=self.__connect_timeout, + max_reconnect_attempts=self.__max_reconnects, + allow_reconnect=self.__max_reconnects != 0, + error_cb=self.__error_cb, + disconnected_cb=self.__disconnected_cb, + reconnected_cb=self.__reconnected_cb, + ) + if self.__token: + opts["token"] = self.__token + elif self.__username is not None: + opts["user"] = self.__username + opts["password"] = self.__password if self.__password is not None else "" + self.__nc = await _nats.connect(**opts) + self.__is_connected = True + logger.info('NATS connected to %s', self.__url) + if self.__on_connect is not None: + self.__on_connect(self, None, None, 0, None) + + async def __error_cb(self, exc): + logger.error('NATS error: %s', exc) + + async def __disconnected_cb(self): + self.__is_connected = False + if self.__closing: + logger.info('NATS disconnected from %s (shutting down)', self.__url) + return + logger.warning('NATS disconnected from %s — will attempt reconnect', self.__url) + if self.__on_disconnect is not None: + self.__on_disconnect(self, None, None, 1, None) + + async def __reconnected_cb(self): + self.__is_connected = True + logger.info('NATS reconnected to %s', self.__url) + + def start(self): + """No-op for interface parity. + + The background event loop is already running (started in + ``__init__``) and I/O is serviced by nats-py on that loop as soon as + :meth:`connect` returns; there is no separate network loop to start + as there is with paho's ``loop_start``. + """ + return + + def stop(self): + """Drain, close the connection, and stop the background loop cleanly.""" + self.__closing = True + try: + if self.__nc is not None: + self.__call_sync(self.__close(), timeout=5) + except Exception as exc: + logger.debug('NATS close error (ignored): %s', exc) + finally: + self.__loop.call_soon_threadsafe(self.__loop.stop) + self.__thread.join(timeout=5) + self.__is_connected = False + + async def __close(self): + try: + await self.__nc.drain() + finally: + self.__is_connected = False + + def disconnect(self): + """Close the NATS connection but leave the background loop running.""" + self.__closing = True + if self.__nc is not None: + try: + self.__call_sync(self.__nc.drain(), timeout=5) + finally: + self.__is_connected = False + + # -- pub/sub -------------------------------------------------------------- + + def subscribe(self, topic, qos=0, msg_callback=None): + """Subscribe to *topic* (an MQTT-style or native NATS subject). + + :param topic: subject to subscribe to; MQTT-style separators/wildcards + are translated via :func:`nats_subject_from_topic`. + :param qos: accepted for MQTTCommClient parity and ignored (NATS core + has no QoS levels). + :param msg_callback: ``callback(client, userdata, msg)`` invoked on the + loop thread for each message, where ``msg`` exposes ``topic`` and + ``payload``. Without it, messages are received but dropped. + + .. warning:: + The callback runs on this client's event-loop thread. Do **not** + call :meth:`publish` (or any other blocking method here) from + inside it — each of those blocks on ``run_coroutine_threadsafe`` + waiting for the same loop, which would deadlock. Hand work off to + another thread / the application loop instead (the built-in + resource callbacks already do this via the inbound deque). + """ + subject = nats_subject_from_topic(topic) + if not self.__is_connected: + logger.warning('NATS subscribe called on %s while not connected', subject) + self.__call_sync(self.__subscribe(subject, msg_callback), timeout=5) + logger.debug('NATS subscribed to subject: %s', subject) + + async def __subscribe(self, subject, msg_callback): + async def _handler(msg): + shim = _NatsMsgShim(msg.subject, msg.data, getattr(msg, 'headers', None)) + try: + if msg_callback is not None: + msg_callback(self, None, shim) + if self.__on_message is not None: + self.__on_message(self, None, shim) + except Exception: + logger.exception('NATS message callback error on %s', msg.subject) + + sub = await self.__nc.subscribe(subject, cb=_handler) + self.__subs[subject] = sub + + def publish(self, topic, payload=None, qos=0, retain=False): + """Publish *payload* to *topic*. + + :param payload: ``bytes`` (or ``str``, encoded UTF-8); ``None`` sends + an empty message. + :param qos: accepted for parity and ignored. + :param retain: accepted for parity and ignored (NATS core has no + retained messages). + """ + subject = nats_subject_from_topic(topic) + if not self.__is_connected: + logger.warning('NATS publish called on %s while not connected — message may be lost', subject) + data = self.__coerce_payload(payload) + try: + self.__call_sync(self.__nc.publish(subject, data), timeout=5) + except Exception as exc: + logger.error('NATS publish error on %s: %s', subject, exc) + + @staticmethod + def __coerce_payload(payload): + if payload is None: + return b'' + if isinstance(payload, str): + return payload.encode('utf-8') + return bytes(payload) + + def unsubscribe(self, topic): + subject = nats_subject_from_topic(topic) + sub = self.__subs.pop(subject, None) + if sub is not None: + try: + self.__call_sync(sub.unsubscribe(), timeout=5) + except Exception as exc: + logger.debug('NATS unsubscribe error on %s (ignored): %s', subject, exc) + logger.debug('NATS unsubscribed from subject: %s', subject) + + def is_connected(self): + return self.__is_connected and self.__nc is not None and self.__nc.is_connected + + # -- callback setters (MQTTCommClient parity) ----------------------------- + + def set_on_connect(self, on_connect): + """Set the connect callback ``fn(client, userdata, flags, rc, props)``.""" + self.__on_connect = on_connect + + def set_on_disconnect(self, on_disconnect): + """Set the disconnect callback ``fn(client, userdata, flag, rc, props)``.""" + self.__on_disconnect = on_disconnect + + def set_on_subscribe(self, on_subscribe): + """Stored for parity; NATS has no subscribe-acknowledged callback.""" + self.__on_subscribe = on_subscribe + + def set_on_unsubscribe(self, on_unsubscribe): + """Stored for parity; NATS has no unsubscribe-acknowledged callback.""" + self.__on_unsubscribe = on_unsubscribe + + def set_on_publish(self, on_publish): + """Stored for parity; NATS core publishes are fire-and-forget.""" + self.__on_publish = on_publish + + def set_on_message(self, on_message): + """Set a catch-all message callback invoked in addition to per-subject ones.""" + self.__on_message = on_message + + def set_on_log(self, on_log): + """Stored for parity; nats-py logs via the standard logging module.""" + self.__on_log = on_log + + def set_on_message_callback(self, sub, on_message_callback): + """Subscribe *sub* with a dedicated per-subject callback.""" + self.subscribe(sub, msg_callback=on_message_callback) diff --git a/src/oshconnect/node.py b/src/oshconnect/node.py index 7cba2a6..36f8e2c 100644 --- a/src/oshconnect/node.py +++ b/src/oshconnect/node.py @@ -34,6 +34,7 @@ from .csapi4py.constants import APIResourceTypes from .csapi4py.default_api_helpers import APIHelper from .csapi4py.mqtt import MQTTCommClient +from .csapi4py.nats import NatsCommClient from .resource_datamodels import SystemResource if TYPE_CHECKING: @@ -182,10 +183,13 @@ class Node: _client_session: OSHClientSession _mqtt_client: MQTTCommClient _mqtt_port: int = 1883 + _nats_client: NatsCommClient + _nats_port: int = 4222 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): + session_manager: SessionManager = None, enable_mqtt: bool = False, mqtt_port: int = 1883, + enable_nats: bool = False, nats_port: int = 4222, nats_token: str = None): self._id = f'node-{uuid.uuid4()}' self.protocol = protocol self.address = address @@ -216,6 +220,14 @@ def __init__(self, protocol: str, address: str, port: int, username: str = None, self._mqtt_client.connect() self._mqtt_client.start() + if enable_nats: + self._nats_port = nats_port + self._nats_client = NatsCommClient(url=self.address, port=self._nats_port, username=username, + password=password, token=nats_token, + client_id_suffix=uuid.uuid4().hex) + self._nats_client.connect() + self._nats_client.start() + def get_id(self) -> str: """Return the locally-generated node ID (``node-``).""" return self._id @@ -250,6 +262,23 @@ def get_mqtt_client(self) -> MQTTCommClient: not enabled at construction (``enable_mqtt=True``).""" return getattr(self, '_mqtt_client', None) + def get_nats_client(self) -> NatsCommClient: + """Return the connected `NatsCommClient` or ``None`` if NATS was + not enabled at construction (``enable_nats=True``).""" + return getattr(self, '_nats_client', None) + + def get_comm_client(self): + """Return the active pub/sub transport client for this node. + + Prefers NATS when it was enabled, otherwise falls back to MQTT (and + ``None`` if neither was enabled). `StreamableResource` reads this so + it can drive whichever transport the node was configured with. + """ + nats_client = self.get_nats_client() + if nats_client is not None: + return nats_client + return self.get_mqtt_client() + def discover_systems(self) -> list[System] | None: """GET ``/systems?f=application/sml+json`` and create a `System` for each entry. diff --git a/src/oshconnect/resources/base.py b/src/oshconnect/resources/base.py index 2e9c740..43b673e 100644 --- a/src/oshconnect/resources/base.py +++ b/src/oshconnect/resources/base.py @@ -40,7 +40,9 @@ from uuid import UUID, uuid4 from ..csapi4py.constants import APIResourceTypes -from ..csapi4py.mqtt import MQTTCommClient +from ..csapi4py.default_api_helpers import resource_type_to_endpoint +from ..csapi4py.mqtt import MQTTCommClient, mqtt_topic_format_token +from ..csapi4py.nats import NatsCommClient from ..resource_datamodels import ControlStreamResource from ..resource_datamodels import DatastreamResource from ..resource_datamodels import SystemResource @@ -119,6 +121,7 @@ class StreamableResource(Generic[T], ABC): _inbound_deque: deque _outbound_deque: deque _mqtt_client: MQTTCommClient + _subscribe_topic: str _parent_resource_id: str _connection_mode: StreamableModes = StreamableModes.PUSH.value @@ -126,10 +129,11 @@ def __init__(self, node: Node, connection_mode: StreamableModes = StreamableMode self._id = uuid4() self._parent_node = node self._parent_node.register_streamable(self) - self._mqtt_client = self._parent_node.get_mqtt_client() + self._mqtt_client = self._parent_node.get_comm_client() self._connection_mode = connection_mode self._inbound_deque = deque() self._outbound_deque = deque() + self._subscribe_topic = None self._parent_resource_id = None def get_streamable_id(self) -> UUID: @@ -269,6 +273,119 @@ def get_mqtt_topic(self, subresource: APIResourceTypes | None = None, data_topic format=format) return topic + def uses_nats(self) -> bool: + """True when this resource's active transport is NATS. + + Drives the flat-vs-nested topic choice: NATS data subjects nest under + ``systems`` (and need the parent system id) where the MQTT topics are + flat. See `get_nats_subject`. + """ + return isinstance(self._mqtt_client, NatsCommClient) + + def get_stream_topic(self, subresource: APIResourceTypes | None = None, data_topic: bool = True, + format: str | None = None) -> str: + """Return the data topic/subject for the *active* transport. + + Dispatches to `get_nats_subject` (nested-under-systems, dot-delimited) + when NATS is active, else `get_mqtt_topic` (flat, slash-delimited). + Subclasses call this from ``init_mqtt`` so they stay transport-agnostic. + """ + if self.uses_nats(): + return self.get_nats_subject(subresource=subresource, data_topic=data_topic, format=format) + return self.get_mqtt_topic(subresource=subresource, data_topic=data_topic, format=format) + + def get_subscribe_topic(self, subresource: APIResourceTypes | None = None, + format: str | None = None) -> str: + """Return the topic/subject to *subscribe* to for inbound data. + + For MQTT this is the exact per-format topic (the broker negotiates the + format per subscription). For NATS it is a format-wildcard subject + (``…:data.*``): in PROACTIVE mode the server publishes each stream on a + single server-chosen format regardless of the subscriber, so we accept + whatever it emits and read the concrete format back from each delivered + subject via + :func:`~oshconnect.csapi4py.nats.nats_content_type_from_subject`. + """ + if self.uses_nats(): + return self.get_nats_subject(subresource=subresource, data_topic=True, format_wildcard=True) + return self.get_mqtt_topic(subresource=subresource, data_topic=True, format=format) + + def get_nats_subject(self, subresource: APIResourceTypes | None = None, data_topic: bool = True, + format: str | None = None, format_wildcard: bool = False) -> str: + """Build the CS API Part 3 NATS data subject for this resource. + + Unlike the flat MQTT topics, NATS data subjects are the canonical + *nested-under-systems* resource path, dot-delimited, with a + ``:data[.]`` suffix — e.g. + ``api.systems.{sysId}.datastreams.{dsId}.observations:data.swe-proto``. + This mirrors ``ConSysApiNatsConnector.getResourceUri`` on the server, + which reverses a subject back into ``/systems/.../observations`` by + stripping the ``:data`` suffix and replacing ``.`` with ``/``. + + When ``format_wildcard`` is set, the format subtopic is a NATS + single-token wildcard (``…:data.*``) instead of a concrete token. + This is used to *subscribe* in PROACTIVE mode, where the server + publishes on one server-chosen format subtopic regardless of the + datastream's own obs format — the actual format is then read back + from each delivered subject's trailing token (see + :func:`~oshconnect.csapi4py.nats.nats_content_type_from_subject`). + + The parent system id required for the nesting is read from the + datastream's ``system_id`` (``system@id``) or — for control streams + and locally-created datastreams — from ``_parent_resource_id``. + + :raises ValueError: if the underlying resource type is unsupported or + the parent system id cannot be resolved. + """ + parts = [self._parent_node.get_api_helper().get_mqtt_root()] + collection_type = None + + if isinstance(self._underlying_resource, DatastreamResource): + sys_id = getattr(self._underlying_resource, "system_id", None) or self._parent_resource_id + parts += [resource_type_to_endpoint(APIResourceTypes.SYSTEM), sys_id, + resource_type_to_endpoint(APIResourceTypes.DATASTREAM), self._resource_id] + collection_type = APIResourceTypes.OBSERVATION + elif isinstance(self._underlying_resource, ControlStreamResource): + sys_id = self._parent_resource_id + parts += [resource_type_to_endpoint(APIResourceTypes.SYSTEM), sys_id, + resource_type_to_endpoint(APIResourceTypes.CONTROL_CHANNEL), self._resource_id] + # Command status subjects follow the same nesting convention as + # command subjects. The reference server publishes observation and + # command data subjects explicitly; the status data subject is + # built by analogy — see docs/osh_spec_deviations.md#nats-status-subject. + collection_type = (APIResourceTypes.STATUS if subresource is APIResourceTypes.STATUS + else APIResourceTypes.COMMAND) + elif isinstance(self._underlying_resource, SystemResource): + parts += [resource_type_to_endpoint(APIResourceTypes.SYSTEM), self._resource_id] + match subresource: + case APIResourceTypes.DATASTREAM: + collection_type = APIResourceTypes.DATASTREAM + case APIResourceTypes.CONTROL_CHANNEL: + collection_type = APIResourceTypes.CONTROL_CHANNEL + case None: + collection_type = None + case _: + raise ValueError(f"Unsupported subresource type {subresource} for SystemResource.") + else: + raise ValueError("Underlying resource must be a System, Datastream, or ControlStream resource.") + + if any(p is None for p in parts): + raise ValueError( + "Cannot build NATS subject: parent system id is unresolved. Ensure the resource " + "was discovered with its 'system@id' or had set_parent_resource_id() called.") + + if collection_type is not None: + parts.append(resource_type_to_endpoint(collection_type)) + + subject = ".".join(str(p) for p in parts) + if data_topic: + subject += ":data" + if format_wildcard: + subject += ".*" + elif format is not None: + subject += f".{mqtt_topic_format_token(format)}" + return subject + 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 @@ -280,8 +397,12 @@ def get_event_topic(self) -> str: 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}' + # Prefer the nested-under-system path (required by the NATS event + # subjects, and valid for MQTT too). Fall back to the datastream's + # own ``system@id`` when no parent id was assigned locally. + sys_id = self._parent_resource_id or getattr(self._underlying_resource, "system_id", None) + if sys_id: + return f'{mqtt_root}/systems/{sys_id}/datastreams/{self._resource_id}' return f'{mqtt_root}/datastreams/{self._resource_id}' elif isinstance(self._underlying_resource, ControlStreamResource): diff --git a/src/oshconnect/resources/controlstream.py b/src/oshconnect/resources/controlstream.py index c6827e5..fb20d31 100644 --- a/src/oshconnect/resources/controlstream.py +++ b/src/oshconnect/resources/controlstream.py @@ -45,6 +45,7 @@ class ControlStream(StreamableResource[ControlStreamResource]): model that backs this stream. """ _status_topic: str + _status_subscribe_topic: str _inbound_status_deque: deque _outbound_status_deque: deque @@ -53,9 +54,16 @@ def __init__(self, node: Node = None, controlstream_resource: ControlStreamResou self._underlying_resource = controlstream_resource self._inbound_status_deque = deque() self._outbound_status_deque = deque() + self._status_subscribe_topic = None 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() + # Always make sure this is set after the resource ids are set. The NATS + # variant nests under the parent system, whose id may only be assigned + # later via set_parent_resource_id — tolerate that here and recompute + # in init_mqtt once it is available. + try: + self._status_topic = self.get_mqtt_status_topic() + except ValueError: + self._status_topic = None def add_underlying_resource(self, resource: ControlStreamResource): """Replace the underlying `ControlStreamResource` model.""" @@ -74,15 +82,25 @@ def init_mqtt(self): super().init_mqtt() schema = getattr(self._underlying_resource, "command_schema", None) cmd_format = getattr(schema, "command_format", None) if schema is not None else None - self._topic = self.get_mqtt_topic(subresource=APIResourceTypes.COMMAND, - data_topic=True, format=cmd_format) + self._topic = self.get_stream_topic(subresource=APIResourceTypes.COMMAND, + data_topic=True, format=cmd_format) + # Parent system id is resolved by now (via discovery or + # set_parent_resource_id), so a NATS status subject can be built. + self._status_topic = self.get_mqtt_status_topic() + # Publish uses the exact-format topics above; subscribe uses + # format-wildcard subjects over NATS (server picks the proactive format). + self._subscribe_topic = self.get_subscribe_topic( + subresource=APIResourceTypes.COMMAND, format=cmd_format) + self._status_subscribe_topic = self.get_subscribe_topic( + subresource=APIResourceTypes.STATUS, format="application/json") def get_mqtt_status_topic(self) -> str: - """Return the MQTT topic for command status updates. Status payloads - are always ``application/json``, so the topic is suffixed with the - ``json`` format subtopic (``…/status:data/json``).""" - return self.get_mqtt_topic(subresource=APIResourceTypes.STATUS, - data_topic=True, format="application/json") + """Return the topic/subject for command status updates. Status payloads + are always ``application/json``, so it is suffixed with the ``json`` + format subtopic (``…/status:data/json`` for MQTT, or the nested + ``…commands... status:data.json`` equivalent for NATS).""" + return self.get_stream_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) @@ -99,7 +117,7 @@ def start(self): 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) + self._mqtt_client.subscribe(self._subscribe_topic, msg_callback=self._mqtt_sub_callback) else: try: loop = asyncio.get_running_loop() @@ -174,9 +192,9 @@ def subscribe(self, topic=None, callback=None, qos=0): t = None if topic is None or topic == APIResourceTypes.COMMAND.value: - t = self._topic + t = self._subscribe_topic elif topic == APIResourceTypes.STATUS.value: - t = self._status_topic + t = self._status_subscribe_topic else: raise ValueError( f"Invalid topic {topic!r}; must be None, " diff --git a/src/oshconnect/resources/datastream.py b/src/oshconnect/resources/datastream.py index b24255c..7a21477 100644 --- a/src/oshconnect/resources/datastream.py +++ b/src/oshconnect/resources/datastream.py @@ -120,7 +120,7 @@ def start(self): super().start() if self._mqtt_client is not None: if self._connection_mode is StreamableModes.PULL or self._connection_mode is StreamableModes.BIDIRECTIONAL: - self._mqtt_client.subscribe(self._topic, msg_callback=self._mqtt_sub_callback) + self._mqtt_client.subscribe(self._subscribe_topic, msg_callback=self._mqtt_sub_callback) else: try: loop = asyncio.get_running_loop() @@ -141,8 +141,12 @@ def init_mqtt(self): super().init_mqtt() schema = getattr(self._underlying_resource, "record_schema", None) obs_format = getattr(schema, "obs_format", None) if schema is not None else None - self._topic = self.get_mqtt_topic(subresource=APIResourceTypes.OBSERVATION, - data_topic=True, format=obs_format) + self._topic = self.get_stream_topic(subresource=APIResourceTypes.OBSERVATION, + data_topic=True, format=obs_format) + # Publish uses the exact-format topic above; subscribe uses a + # format-wildcard subject over NATS (server picks the proactive format). + self._subscribe_topic = self.get_subscribe_topic(subresource=APIResourceTypes.OBSERVATION, + format=obs_format) def _emit_inbound_event(self, msg): evt = (EventBuilder().with_type(DefaultEventTypes.NEW_OBSERVATION).with_topic(msg.topic).with_data( @@ -203,12 +207,12 @@ def _encode_for_wire(self, data) -> bytes: } return SWEProtobufCodec(schema).encode(data, envelope=envelope) if isinstance(schema, SWEFlatBuffersDatastreamRecordSchema): - from ..swe_flatbuffers import SWEFlatBuffersCodec # lazy: stub + from ..swe_flatbuffers import SWEFlatBuffersCodec # lazy: optional dep 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: + def decode_observation(self, raw: bytes, obs_format: str | None = None) -> dict: """Decode one observation off the wire using this datastream's schema. For ``application/swe+binary`` datastreams: walks the record @@ -218,6 +222,14 @@ def decode_observation(self, raw: bytes) -> dict: For JSON-family datastreams: returns ``json.loads(raw)``. + :param obs_format: Optional MIME content-type overriding the + datastream's own schema format. Use this to decode data received + via a NATS format-wildcard subscription, where the concrete format + is only known per-message from the delivered subject (pass the + result of + :func:`~oshconnect.csapi4py.nats.nats_content_type_from_subject`). + ``application/swe+flatbuffers`` is schemaless and decodes + regardless of the datastream's own schema type. :raises ValueError: if no schema has been fetched. """ schema = getattr(self._underlying_resource, "record_schema", None) @@ -226,13 +238,34 @@ def decode_observation(self, raw: bytes) -> dict: "Cannot decode observation: no record_schema on this " "datastream. Call System.discover_datastreams() first, " "or set record_schema manually.") + + # Explicit per-message format override (NATS wildcard case) dispatches + # by the given content-type rather than the datastream's schema type. + if obs_format is not None: + if obs_format == "application/swe+flatbuffers": + # FlexBuffers is self-describing — decode with any nominal schema. + from ..swe_flatbuffers import SWEFlatBuffersCodec # lazy: optional dep + inner = schema if isinstance(schema, SWEFlatBuffersDatastreamRecordSchema) \ + else getattr(schema, "record_schema", None) or schema + return SWEFlatBuffersCodec(inner).decode(raw) + if obs_format == "application/swe+binary" and isinstance(schema, SWEBinaryDatastreamRecordSchema): + return SWEBinaryCodec(schema).decode(raw) + if obs_format == "application/swe+proto" and isinstance(schema, SWEProtobufDatastreamRecordSchema): + from ..swe_protobuf import SWEProtobufCodec # lazy: optional dep + return SWEProtobufCodec(schema).decode(raw) + # JSON family (swe+json / json / om+json) and any format whose + # concrete codec needs a matching schema we don't have fall through + # to JSON — the safe default for the text encodings. + return json.loads(raw) + + # No override: dispatch by the datastream's own schema type. 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 + from ..swe_flatbuffers import SWEFlatBuffersCodec # lazy: optional dep return SWEFlatBuffersCodec(schema).decode(raw) return json.loads(raw) @@ -288,7 +321,7 @@ def subscribe(self, topic=None, callback=None, qos=0): t = None if topic is None or topic == APIResourceTypes.OBSERVATION.value: - t = self._topic + t = self._subscribe_topic # elif topic == APIResourceTypes.STATUS.value: # t = self._status_topic else: diff --git a/src/oshconnect/swe_flatbuffers.py b/src/oshconnect/swe_flatbuffers.py index e16880d..af7802a 100644 --- a/src/oshconnect/swe_flatbuffers.py +++ b/src/oshconnect/swe_flatbuffers.py @@ -7,54 +7,62 @@ """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. +OpenSensorHub encodes ``swe+flatbuffers`` observations as **schemaless +FlexBuffers**, not schema-compiled FlatBuffers. Each observation is a +length-prefixed frame: a 4-byte big-endian length followed by one +FlexBuffers document (see ``FlexFraming`` in +``sensorhub-service-consys-flatbuffers`` — "Over NATS each proactive +observation is published as one message whose payload is one such +frame"). The FlexBuffers root is located from the *end* of the buffer, +which is why concatenated bare documents would be unsplittable and the +length prefix is required. + +FlexBuffers is self-describing, so decoding needs no compiled bindings — +``flatbuffers.flexbuffers.Loads`` returns a plain ``dict`` keyed by the +SWE field names (``phenomenon_time``, ``result_time``, ``result`` …). +This sidesteps the ``flatc --python`` "vectors of unions" limitation +(``docs/osh_spec_deviations.md`` → ``flatc-python-vector-of-union``), +which only blocks *schema-compiled* FlatBuffers bindings — a path OSH's +wire format does not use. + +Requires the optional ``flatbuffers`` dependency +(``pip install oshconnect[flatbuffers]``); the import is deferred so the +rest of the SDK can register and round-trip schemas naming this format +without it installed. """ from __future__ import annotations +import struct from typing import Any, Union from .schema_datamodels import SWEFlatBuffersDatastreamRecordSchema from .swe_components import AnyComponentSchema +# 4-byte big-endian frame length prefix, matching the Java ``FlexFraming``. +_FRAME_LEN = struct.Struct(">I") -_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)." -) + +def _flexbuffers(): + """Import ``flatbuffers.flexbuffers`` lazily with a helpful error.""" + try: + from flatbuffers import flexbuffers + except ImportError as exc: # pragma: no cover - exercised only without extra + raise ImportError( + "The swe+flatbuffers codec requires the optional 'flatbuffers' " + "dependency. Install it with `pip install oshconnect[flatbuffers]`." + ) from exc + return flexbuffers class SWEFlatBuffersCodec: - """Placeholder for the FlatBuffers SWE codec. + """FlexBuffers codec for ``application/swe+flatbuffers`` observations. - 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. + :meth:`encode` produces a length-prefixed FlexBuffers frame from a + mapping; :meth:`decode` reverses it, returning a ``dict`` keyed by SWE + field name. FlexBuffers is schemaless, so the ``schema`` is retained + only for API symmetry with the other codecs and is not required to + decode. """ def __init__( @@ -68,8 +76,26 @@ def __init__( 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) + def encode(self, value: Any) -> bytes: + """Encode *value* (a mapping/record) as a length-prefixed FlexBuffers frame.""" + doc = _flexbuffers().Dumps(value) + return _FRAME_LEN.pack(len(doc)) + doc + + def decode(self, buf: bytes) -> Any: + """Decode one FlexBuffers observation frame into a ``dict``. + + Accepts either a length-prefixed frame (as published over NATS/MQTT) + or a bare FlexBuffers document; the 4-byte prefix is stripped when + present. + """ + return _flexbuffers().Loads(self._strip_frame(buf)) + + @staticmethod + def _strip_frame(buf: bytes) -> bytes: + """Strip the 4-byte length prefix when the frame length matches.""" + data = bytes(buf) + if len(data) >= 4: + declared = _FRAME_LEN.unpack(data[:4])[0] + if declared == len(data) - 4: + return data[4:] + return data diff --git a/tests/test_mqtt_topics.py b/tests/test_mqtt_topics.py index 9283dab..22a466f 100644 --- a/tests/test_mqtt_topics.py +++ b/tests/test_mqtt_topics.py @@ -35,6 +35,10 @@ def make_mock_node(api_root="api", mqtt_topic_root=None): node = MagicMock() node.get_api_helper.return_value = api_helper node.get_mqtt_client.return_value = None + # StreamableResource sources its transport via get_comm_client(); alias it + # to get_mqtt_client so per-test `get_mqtt_client.return_value = ...` wiring + # continues to drive the resource's client (these tests exercise MQTT). + node.get_comm_client = node.get_mqtt_client return node diff --git a/tests/test_nats_subjects.py b/tests/test_nats_subjects.py new file mode 100644 index 0000000..1a4ca1d --- /dev/null +++ b/tests/test_nats_subjects.py @@ -0,0 +1,240 @@ +# ============================================================================= +# Copyright (c) 2026 Georobotix Innovative Research +# Author: Ian Patterson +# Contact Email: ian.patterson@georobotix.us +# ============================================================================= + +"""Unit tests for the NATS transport subject conventions. + +Mirrors ``test_mqtt_topics.py`` but for the NATS binding, whose data +subjects are *nested under systems* and dot-delimited — see +``ConSysApiNatsConnector.getResourceUri`` in the reference server +(``sensorhub-service-consys-nats``). No live NATS server is required: +these exercise pure subject construction plus the transport-dispatch +wiring on `StreamableResource`. +""" +from unittest.mock import MagicMock + +import pytest + +from oshconnect.csapi4py.constants import APIResourceTypes +from oshconnect.csapi4py.default_api_helpers import APIHelper +from oshconnect.csapi4py.nats import ( + NatsCommClient, + nats_content_type_from_subject, + nats_subject_from_topic, +) +from oshconnect.resource_datamodels import ( + ControlStreamResource, + DatastreamResource, +) +from oshconnect.resources.controlstream import ControlStream +from oshconnect.resources.datastream import Datastream +from oshconnect.resources.system import System + +SYS_ID = "sys-1" +DS_ID = "ds-1" +CS_ID = "cs-1" + + +def make_mock_node(api_root="api", mqtt_topic_root=None, comm_client=None): + """Mock Node with a real APIHelper so ``get_mqtt_root`` is exercised. + + ``comm_client`` is returned from ``get_comm_client`` — pass a + `NatsCommClient` to make resources report ``uses_nats() is True``. + """ + api_helper = APIHelper(server_url="localhost", port=8282, protocol="http", + server_root="sensorhub", api_root=api_root, + mqtt_topic_root=mqtt_topic_root) + node = MagicMock() + node.get_api_helper.return_value = api_helper + node.get_mqtt_client.return_value = None + node.get_comm_client.return_value = comm_client + return node + + +def make_ds(node, ds_id=DS_ID, system_id=SYS_ID): + payload = { + "id": ds_id, "name": "d", + "validTime": ["2024-01-01T00:00:00Z", "2025-01-01T00:00:00Z"], + } + if system_id is not None: + payload["system@id"] = system_id + res = DatastreamResource.model_validate(payload) + return Datastream(parent_node=node, datastream_resource=res) + + +def make_cs(node, cs_id=CS_ID, system_id=SYS_ID): + res = ControlStreamResource.model_validate({"id": cs_id, "name": "c"}) + cs = ControlStream(node=node, controlstream_resource=res) + if system_id is not None: + cs.set_parent_resource_id(system_id) + return cs + + +# --------------------------------------------------------------------------- +# nats_subject_from_topic — delimiter/wildcard translation +# --------------------------------------------------------------------------- + +class TestSubjectTranslation: + def test_slash_becomes_dot(self): + assert (nats_subject_from_topic("api/datastreams/ds1/observations:data/swe-proto") + == "api.datastreams.ds1.observations:data.swe-proto") + + def test_single_and_multi_level_wildcards(self): + assert (nats_subject_from_topic("api/datastreams/+/observations/#") + == "api.datastreams.*.observations.>") + + def test_already_nats_form_passthrough(self): + subject = "api.systems.sys-1.datastreams.ds-1.observations:data.swe-json" + assert nats_subject_from_topic(subject) == subject + + +class TestContentTypeFromSubject: + def test_flatbuffers_token(self): + assert (nats_content_type_from_subject( + "api.systems.s.datastreams.d.observations:data.swe-flatbuffers") + == "application/swe+flatbuffers") + + def test_swe_json_token(self): + assert (nats_content_type_from_subject( + "api.systems.s.datastreams.d.observations:data.swe-json") + == "application/swe+json") + + def test_bare_data_returns_none(self): + assert nats_content_type_from_subject( + "api.systems.s.datastreams.d.observations:data") is None + + def test_unknown_token_returns_none(self): + assert nats_content_type_from_subject( + "api.systems.s.datastreams.d.observations:data.bogus") is None + + def test_no_data_suffix_returns_none(self): + assert nats_content_type_from_subject("api.systems.s") is None + + +# --------------------------------------------------------------------------- +# get_nats_subject — nested-under-systems data subjects +# --------------------------------------------------------------------------- + +class TestDatastreamSubject: + def test_observation_subject_is_nested_under_system(self): + ds = make_ds(make_mock_node()) + assert (ds.get_nats_subject(subresource=APIResourceTypes.OBSERVATION) + == "api.systems.sys-1.datastreams.ds-1.observations:data") + + def test_format_token_appended(self): + ds = make_ds(make_mock_node()) + assert (ds.get_nats_subject(subresource=APIResourceTypes.OBSERVATION, + format="application/swe+proto") + == "api.systems.sys-1.datastreams.ds-1.observations:data.swe-proto") + + def test_event_subject_has_no_data_suffix(self): + ds = make_ds(make_mock_node()) + assert (ds.get_nats_subject(subresource=APIResourceTypes.OBSERVATION, + data_topic=False) + == "api.systems.sys-1.datastreams.ds-1.observations") + + def test_parent_resource_id_fallback_when_system_id_absent(self): + ds = make_ds(make_mock_node(), system_id=None) + ds.set_parent_resource_id("sys-fallback") + assert (ds.get_nats_subject(subresource=APIResourceTypes.OBSERVATION) + == "api.systems.sys-fallback.datastreams.ds-1.observations:data") + + def test_missing_system_id_raises(self): + ds = make_ds(make_mock_node(), system_id=None) + with pytest.raises(ValueError, match="parent system id"): + ds.get_nats_subject(subresource=APIResourceTypes.OBSERVATION) + + def test_custom_api_root_becomes_subject_prefix(self): + ds = make_ds(make_mock_node(api_root="v2")) + assert ds.get_nats_subject(subresource=APIResourceTypes.OBSERVATION).startswith( + "v2.systems.sys-1.datastreams.ds-1.observations") + + def test_mqtt_topic_root_overrides_prefix(self): + ds = make_ds(make_mock_node(mqtt_topic_root="bus")) + assert ds.get_nats_subject(subresource=APIResourceTypes.OBSERVATION).startswith( + "bus.systems.sys-1.datastreams.ds-1.observations") + + +class TestControlStreamSubject: + def test_command_subject_nested_under_system(self): + cs = make_cs(make_mock_node()) + assert (cs.get_nats_subject(subresource=APIResourceTypes.COMMAND) + == "api.systems.sys-1.controlstreams.cs-1.commands:data") + + def test_status_subject_nested_under_system(self): + cs = make_cs(make_mock_node()) + assert (cs.get_nats_subject(subresource=APIResourceTypes.STATUS, + format="application/json") + == "api.systems.sys-1.controlstreams.cs-1.status:data.json") + + +class TestSystemSubject: + def test_system_event_subject(self): + node = make_mock_node() + sysres = System(label="s", urn="urn:x", parent_node=node, resource_id=SYS_ID) + assert sysres.get_nats_subject(data_topic=False) == "api.systems.sys-1" + + def test_system_datastreams_collection_subject(self): + node = make_mock_node() + sysres = System(label="s", urn="urn:x", parent_node=node, resource_id=SYS_ID) + assert (sysres.get_nats_subject(subresource=APIResourceTypes.DATASTREAM) + == "api.systems.sys-1.datastreams:data") + + +# --------------------------------------------------------------------------- +# Transport dispatch — get_stream_topic / init_mqtt pick the right form +# --------------------------------------------------------------------------- + +@pytest.fixture +def nats_client(): + """A NatsCommClient that never connects; loop thread is stopped after use.""" + client = NatsCommClient(url="localhost", port=4222, client_id_suffix="test") + yield client + client.stop() + + +class TestTransportDispatch: + def test_uses_nats_true_with_nats_client(self, nats_client): + ds = make_ds(make_mock_node(comm_client=nats_client)) + assert ds.uses_nats() is True + + def test_uses_nats_false_without_client(self): + ds = make_ds(make_mock_node(comm_client=None)) + assert ds.uses_nats() is False + + def test_get_stream_topic_nested_for_nats(self, nats_client): + ds = make_ds(make_mock_node(comm_client=nats_client)) + assert (ds.get_stream_topic(subresource=APIResourceTypes.OBSERVATION) + == "api.systems.sys-1.datastreams.ds-1.observations:data") + + def test_get_stream_topic_flat_for_mqtt(self): + ds = make_ds(make_mock_node(comm_client=None)) + # Flat MQTT topic: datastream at top level, slash-delimited. + assert (ds.get_stream_topic(subresource=APIResourceTypes.OBSERVATION) + == "api/datastreams/ds-1/observations:data") + + def test_nats_subscribe_topic_is_format_wildcard(self, nats_client): + ds = make_ds(make_mock_node(comm_client=nats_client)) + assert (ds.get_subscribe_topic(subresource=APIResourceTypes.OBSERVATION) + == "api.systems.sys-1.datastreams.ds-1.observations:data.*") + + def test_mqtt_subscribe_topic_is_exact_format(self): + ds = make_ds(make_mock_node(comm_client=None)) + assert (ds.get_subscribe_topic(subresource=APIResourceTypes.OBSERVATION, + format="application/swe+json") + == "api/datastreams/ds-1/observations:data/swe-json") + + def test_datastream_init_mqtt_sets_nested_topic(self, nats_client): + ds = make_ds(make_mock_node(comm_client=nats_client)) + ds.init_mqtt() + # Publish topic is bare :data (no schema/format); subscribe is wildcard. + assert ds._topic == "api.systems.sys-1.datastreams.ds-1.observations:data" + assert ds._subscribe_topic == "api.systems.sys-1.datastreams.ds-1.observations:data.*" + + def test_controlstream_init_mqtt_sets_nested_topics(self, nats_client): + cs = make_cs(make_mock_node(comm_client=nats_client)) + cs.init_mqtt() + assert cs._topic == "api.systems.sys-1.controlstreams.cs-1.commands:data" + assert cs._status_topic == "api.systems.sys-1.controlstreams.cs-1.status:data.json" diff --git a/tests/test_swe_binary.py b/tests/test_swe_binary.py index 1cec981..4a0936c 100644 --- a/tests/test_swe_binary.py +++ b/tests/test_swe_binary.py @@ -404,6 +404,9 @@ def register_streamable(self, _streamable): def get_mqtt_client(self): return None + def get_comm_client(self): + return None + def _make_binary_datastream(): """Build a Datastream wired to a swe+binary schema, with the MQTT publish diff --git a/tests/test_swe_flatbuffers.py b/tests/test_swe_flatbuffers.py index c96ce85..bd230f1 100644 --- a/tests/test_swe_flatbuffers.py +++ b/tests/test_swe_flatbuffers.py @@ -5,17 +5,21 @@ # Contact Email: ian.patterson@georobotix.us # ============================================================================= -"""Tests for the ``application/swe+flatbuffers`` placeholder codec. +"""Tests for the ``application/swe+flatbuffers`` FlexBuffers 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. +OSH encodes swe+flatbuffers observations as length-prefixed FlexBuffers +frames (schemaless), which Python decodes without compiled bindings. These +verify schema round-trip/parsing, the format picker, and that the codec +round-trips the length-prefixed FlexBuffers framing. """ from __future__ import annotations +import struct + import pytest +flexbuffers = pytest.importorskip("flatbuffers.flexbuffers") + from oshconnect import ( DataRecordSchema, QuantitySchema, SWEFlatBuffersCodec, SWEFlatBuffersDatastreamRecordSchema, TimeSchema, @@ -56,25 +60,37 @@ def test_schema_round_trips_via_any_datastream_record_schema(): 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_encode_prepends_length_prefix(): + codec = SWEFlatBuffersCodec( + SWEFlatBuffersDatastreamRecordSchema(record_schema=_minimal_record())) + frame = codec.encode({"time": 1.5, "x": 1.0}) + declared = struct.unpack(">I", frame[:4])[0] + assert declared == len(frame) - 4 + + +def test_encode_decode_round_trip(): + codec = SWEFlatBuffersCodec( + SWEFlatBuffersDatastreamRecordSchema(record_schema=_minimal_record())) + rec = { + "phenomenon_time": 1782883654.342, + "result_time": 1782883654.342, + "result": {"temperature": 22.4, "pressure": 1012.7, "windSpeed": 5.4}, + } + assert codec.decode(codec.encode(rec)) == rec -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_decode_accepts_framed_and_bare_documents(): + codec = SWEFlatBuffersCodec( + SWEFlatBuffersDatastreamRecordSchema(record_schema=_minimal_record())) + doc = flexbuffers.Dumps({"a": 1, "b": 2}) + framed = struct.pack(">I", len(doc)) + doc + assert codec.decode(framed) == {"a": 1, "b": 2} # length-prefixed frame + assert codec.decode(doc) == {"a": 1, "b": 2} # bare document 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).""" + """Format picker should advertise swe+flatbuffers, winning 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", diff --git a/tests/test_swe_protobuf.py b/tests/test_swe_protobuf.py index 8b52907..23b9c31 100644 --- a/tests/test_swe_protobuf.py +++ b/tests/test_swe_protobuf.py @@ -330,6 +330,7 @@ def test_datastream_insert_routes_through_protobuf_codec(): class _StubNode: def register_streamable(self, _s): pass def get_mqtt_client(self): return None + def get_comm_client(self): return None payload = { "id": "weather42", diff --git a/uv.lock b/uv.lock index 3ff947e..bd654ec 100644 --- a/uv.lock +++ b/uv.lock @@ -793,6 +793,15 @@ 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 = "nats-py" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/f0/fc5e93f2b0dd14a202590ad9d30eda1955ea872039b5204357348d0f4b1e/nats_py-2.15.0.tar.gz", hash = "sha256:6622c547d9a7d2313d9c147d46c386188f4ec2c7b5c9f9a0438a4d1b55f54a93", size = 75995, upload-time = "2026-06-05T07:34:03.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/a8/b55606c7c621fb813c8ec78baf201d2c78bf6051091ec0c7ada572999e95/nats_py-2.15.0-py3-none-any.whl", hash = "sha256:9f8d36aa52a9926a88b8f1d70cf1fdce0ad387941479b500ee9ab3e51073cefd", size = 90334, upload-time = "2026-06-05T07:34:02.81Z" }, +] + [[package]] name = "numpy" version = "2.4.4" @@ -860,6 +869,7 @@ version = "0.5.1a22" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, + { name = "nats-py" }, { name = "paho-mqtt" }, { name = "pydantic" }, { name = "requests" }, @@ -904,6 +914,7 @@ requires-dist = [ { 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 = "nats-py", specifier = ">=2.6.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" }, From fe46e4d38cf5fea2ab6476722e8607e5b2e124a4 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Wed, 1 Jul 2026 15:05:19 -0500 Subject: [PATCH 32/33] feat: add legacy (pre-Part-3) MQTT topic mode for backwards compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Older OSH servers predate the CS API Part 3 topic scheme. Add an opt-in legacy mode that reverts MQTT topic construction to the pre-Part-3 form: a leading slash, no ':data' suffix, and no format subtopic (e.g. /api/datastreams/{id}/observations vs api/datastreams/{id}/observations:data/). - APIHelper gains a `legacy_topics` field and a per-call `legacy=` override on get_mqtt_topic(); in legacy mode data_topic/format are ignored. - Node(mqtt_legacy_topics=True) flows the flag into its APIHelper. - Resources are unaware — they call get_mqtt_topic as before and get the legacy strings transparently, so publish and subscribe both use them. - Legacy mode affects MQTT topics only; NATS subjects are unaffected. Tests: TestLegacyTopics covers legacy strings for datastream/controlstream/ status/system, init_mqtt wiring, off-by-default, and per-call override. Docs: tutorial note by the MQTT section. --- docs/source/tutorial.rst | 15 +++++ .../csapi4py/default_api_helpers.py | 19 ++++-- src/oshconnect/node.py | 5 +- tests/test_mqtt_topics.py | 66 ++++++++++++++++++- 4 files changed, 98 insertions(+), 7 deletions(-) diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index d1a7a0d..a357c59 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -61,6 +61,21 @@ To connect a node with MQTT support for streaming: enable_mqtt=True, mqtt_port=1883) app.add_node(node) +To talk to an **older OSH server** that predates the CS API Part 3 topic +scheme, enable legacy topics. This reverts MQTT topic construction to the +pre-Part-3 form — a leading slash and no ``:data`` suffix or format +subtopic (e.g. ``/api/datastreams/{id}/observations`` instead of +``api/datastreams/{id}/observations:data/``): + +.. code-block:: python + + node = Node(protocol='http', address='localhost', port=8585, + username='test', password='test', + enable_mqtt=True, mqtt_legacy_topics=True) + app.add_node(node) + +Legacy mode affects MQTT topics only; NATS subjects are unaffected. + To stream over **NATS.io** instead (the corporate-bus transport, served by OSH's ``sensorhub-service-consys-nats`` binding), enable it the same way — ``enable_nats`` mirrors ``enable_mqtt``: diff --git a/src/oshconnect/csapi4py/default_api_helpers.py b/src/oshconnect/csapi4py/default_api_helpers.py index ed4a40e..f839112 100644 --- a/src/oshconnect/csapi4py/default_api_helpers.py +++ b/src/oshconnect/csapi4py/default_api_helpers.py @@ -97,6 +97,10 @@ class APIHelper(ABC): username: str = None password: str = None user_auth: bool = False + # When True, build the pre-Part-3 ("legacy") MQTT topic form: a leading + # slash, no ``:data`` suffix, and no format subtopic — for talking to + # older OSH servers that predate the CS API Part 3 topic scheme. + legacy_topics: bool = False def get_mqtt_root(self) -> str: """ @@ -292,7 +296,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, format: str | None = None): + data_topic: bool = True, format: str | None = None, legacy: bool | 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 @@ -308,15 +312,22 @@ def get_mqtt_topic(self, resource_type, subresource_type, resource_id: str, subr §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`. + :param legacy: Force the pre-Part-3 topic form (leading slash, no ``:data`` suffix, no format subtopic). + ``None`` (default) uses this helper's ``legacy_topics`` setting; pass ``True``/``False`` to override per-call. + In legacy mode ``data_topic`` and ``format`` are ignored. :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)}' + use_legacy = self.legacy_topics if legacy is None else legacy 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}' subresource_ident = "" if subresource_id is None else f'/{subresource_id}' + if use_legacy: + # Pre-Part-3 form: leading slash, no ``:data``/format subtopic. + return f'/{self.get_mqtt_root()}{resource_endpoint}{resource_ident}{subresource_endpoint}{subresource_ident}' + 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)}' topic_locator = f'{self.get_mqtt_root()}{resource_endpoint}{resource_ident}{subresource_endpoint}{data_suffix}{subresource_ident}' return topic_locator diff --git a/src/oshconnect/node.py b/src/oshconnect/node.py index 36f8e2c..e66eaab 100644 --- a/src/oshconnect/node.py +++ b/src/oshconnect/node.py @@ -189,7 +189,8 @@ class Node: 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, - enable_nats: bool = False, nats_port: int = 4222, nats_token: str = None): + enable_nats: bool = False, nats_port: int = 4222, nats_token: str = None, + mqtt_legacy_topics: bool = False): self._id = f'node-{uuid.uuid4()}' self.protocol = protocol self.address = address @@ -202,7 +203,7 @@ def __init__(self, protocol: str, address: str, port: int, username: str = None, 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, + username=username, password=password, legacy_topics=mqtt_legacy_topics, ) if self.is_secure: self._api_helper.user_auth = True diff --git a/tests/test_mqtt_topics.py b/tests/test_mqtt_topics.py index 22a466f..dd24f86 100644 --- a/tests/test_mqtt_topics.py +++ b/tests/test_mqtt_topics.py @@ -22,7 +22,7 @@ PARENT_SYS_ID = "sys_parent_001" -def make_mock_node(api_root="api", mqtt_topic_root=None): +def make_mock_node(api_root="api", mqtt_topic_root=None, legacy_topics=False): """Returns a mock Node backed by a real APIHelper so topic construction is exercised.""" api_helper = APIHelper( server_url="localhost", @@ -31,6 +31,7 @@ def make_mock_node(api_root="api", mqtt_topic_root=None): server_root="sensorhub", api_root=api_root, mqtt_topic_root=mqtt_topic_root, + legacy_topics=legacy_topics, ) node = MagicMock() node.get_api_helper.return_value = api_helper @@ -458,3 +459,66 @@ def test_custom_mqtt_topic_root_preserved_with_format(self): format="application/swe+binary", ) assert topic == f"osh/mqtt/datastreams/{DS_ID}/observations:data/swe-binary" + + +class TestLegacyTopics: + """Pre-Part-3 ("legacy") topic form: leading slash, no ``:data`` suffix, + no format subtopic — for backwards compatibility with older OSH servers.""" + + def test_datastream_observation_legacy_topic(self): + ds = make_datastream(make_mock_node(legacy_topics=True)) + # Format is ignored in legacy mode. + topic = ds.get_mqtt_topic(subresource=APIResourceTypes.OBSERVATION, + data_topic=True, format="application/swe+binary") + assert topic == f"/api/datastreams/{DS_ID}/observations" + + def test_controlstream_command_legacy_topic(self): + cs = make_controlstream(make_mock_node(legacy_topics=True)) + topic = cs.get_mqtt_topic(subresource=APIResourceTypes.COMMAND, data_topic=True) + assert topic == f"/api/controlstreams/{CS_ID}/commands" + + def test_controlstream_status_legacy_topic(self): + cs = make_controlstream(make_mock_node(legacy_topics=True)) + topic = cs.get_mqtt_topic(subresource=APIResourceTypes.STATUS, + data_topic=True, format="application/json") + assert topic == f"/api/controlstreams/{CS_ID}/status" + + def test_system_datastreams_collection_legacy_topic(self): + sysres = make_system(make_mock_node(legacy_topics=True)) + topic = sysres.get_mqtt_topic(subresource=APIResourceTypes.DATASTREAM) + assert topic == f"/api/systems/{SYS_ID}/datastreams" + + def test_init_mqtt_sets_legacy_topics(self): + node = make_mock_node(legacy_topics=True) + node.get_mqtt_client.return_value = MagicMock() + + ds = make_datastream(node) + ds.init_mqtt() + assert ds._topic == f"/api/datastreams/{DS_ID}/observations" + # Subscribe topic mirrors publish topic in legacy MQTT (no wildcard). + assert ds._subscribe_topic == f"/api/datastreams/{DS_ID}/observations" + + def test_controlstream_init_mqtt_sets_legacy_topics(self): + node = make_mock_node(legacy_topics=True) + node.get_mqtt_client.return_value = MagicMock() + + cs = make_controlstream(node) + cs.init_mqtt() + assert cs._topic == f"/api/controlstreams/{CS_ID}/commands" + assert cs._status_topic == f"/api/controlstreams/{CS_ID}/status" + + def test_legacy_off_by_default(self): + ds = make_datastream(make_mock_node()) + topic = ds.get_mqtt_topic(subresource=APIResourceTypes.OBSERVATION, data_topic=True) + assert topic == f"api/datastreams/{DS_ID}/observations:data" + + def test_per_call_legacy_override(self): + """Callers can force legacy per-call even when the helper defaults to Part 3.""" + node = make_mock_node() # legacy_topics=False + 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, legacy=True, + ) + assert topic == f"/api/datastreams/{DS_ID}/observations" From fb1719bfc4c4058050344aeee35d815e04e81931 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Thu, 2 Jul 2026 11:12:25 -0500 Subject: [PATCH 33/33] docs: add Prerequisites section and OSH-node connection-info mapping to tutorial Explain that OSHConnect is a client requiring a running OSH node with the Connected Systems API service (and the CS API MQTT module + a broker for real-time streaming), linking the official OpenSensorHub docs rather than duplicating setup instructions. Add a table mapping the information to gather from a node to the corresponding Node() constructor parameters, with a worked example. --- docs/source/tutorial.rst | 73 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index a357c59..d53dd12 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -27,6 +27,79 @@ All public classes and utilities can be imported directly from ``oshconnect``: from oshconnect import ObservationFormat, DefaultEventTypes +Prerequisites +------------- +OSHConnect-Python is a *client*. It talks to a running **OpenSensorHub (OSH) +node** (or any OGC API – Connected Systems server) — it does not start or host +one for you. Before using this library you need: + +- **A running OSH node** with the **Connected Systems API** service enabled. + This is what exposes the HTTP endpoints (Parts 1, 2, and 3) that discovery + and resource creation use. +- **For real-time streaming:** the **Connected Systems API – MQTT** service + module enabled on that node, with an MQTT broker reachable (OSH's default + broker port is ``1883``). Streaming over NATS instead requires the + Connected Systems API – NATS service and a reachable NATS server (default + port ``4222``). +- **Credentials** for the node, if it has security enabled (OSHConnect uses + HTTP Basic Auth). + +Installing, configuring, and enabling those services on an OSH node is outside +the scope of this library. See the official +`OpenSensorHub documentation `_ — in +particular the node setup guides and the +`OSHConnect getting-started guide +`_. + +What you need from your OSH node +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +To point OSHConnect-Python at your node, gather the following from its +configuration and pass them to ``Node`` (see *Adding a Node* below): + +.. list-table:: + :header-rows: 1 + :widths: 24 22 54 + + * - What to find on the node + - ``Node`` parameter + - Notes + * - Protocol + - ``protocol`` + - ``'http'`` or ``'https'``. + * - Host / IP + - ``address`` + - Hostname or IP serving the node, e.g. ``'localhost'``. + * - HTTP port + - ``port`` + - The port the Connected Systems API is served on (e.g. ``8181``). + * - Servlet root + - ``server_root`` + - Context path; OSH default ``'sensorhub'``. The CS API base URL is + ``{protocol}://{address}:{port}/{server_root}/{api_root}/``. + * - API root + - ``api_root`` + - CS API path segment; default ``'api'``. + * - Username / password + - ``username`` / ``password`` + - Only if the node enforces authentication. + * - MQTT broker port + - ``mqtt_port`` (with ``enable_mqtt=True``) + - The node's MQTT broker port for real-time streaming; default ``1883``. + * - NATS server port / token + - ``nats_port`` / ``nats_token`` (with ``enable_nats=True``) + - Only for NATS streaming; default port ``4222``. + +For example, a node whose Connected Systems API answers at +``http://localhost:8585/sensorhub/api/`` with an MQTT broker on ``1883`` +maps to: + +.. code-block:: python + + node = Node(protocol='http', address='localhost', port=8585, + server_root='sensorhub', api_root='api', + enable_mqtt=True, mqtt_port=1883) + + Creating an OSHConnect Instance -------------------------------- The main entry point is the ``OSHConnect`` class: