From b5b930490c131383718e952325ccc0c9c7cd487c Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 1 May 2026 20:39:04 -0500 Subject: [PATCH 1/3] large change to test structure and coverage. These tests are mostly AI generated and will be manually reviewed as time permits --- .github/workflows/tests.yaml | 38 ++ pyproject.toml | 5 +- tests/test_api_helper.py | 18 - tests/test_imports.py | 202 +++------- tests/test_node.py | 24 ++ tests/test_oshconnect.py | 145 +++---- tests/test_serialization.py | 10 - tests/test_streamable_resources.py | 12 - tests/test_swe_components.py | 573 ++++++++++++++++++++++++++++ tests/test_swe_name_validation.py | 394 ------------------- tests/test_swe_schema_validation.py | 371 ------------------ tests/test_time_management.py | 22 ++ uv.lock | 2 +- 13 files changed, 778 insertions(+), 1038 deletions(-) create mode 100644 .github/workflows/tests.yaml delete mode 100644 tests/test_api_helper.py create mode 100644 tests/test_node.py delete mode 100644 tests/test_serialization.py delete mode 100644 tests/test_streamable_resources.py create mode 100644 tests/test_swe_components.py delete mode 100644 tests/test_swe_name_validation.py delete mode 100644 tests/test_swe_schema_validation.py create mode 100644 tests/test_time_management.py diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..4083f73 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,38 @@ +name: Tests +on: [ push, pull_request, workflow_dispatch ] + +permissions: {} + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + pytest: + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + python-version: [ "3.12", "3.13", "3.14" ] + name: pytest (Python ${{ matrix.python-version }}) + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Install Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --all-extras --python ${{ matrix.python-version }} + + # Network-dependent tests need a live OSH server (e.g. localhost:8282). + # They're tagged `@pytest.mark.network` and skipped here. The plan is + # to shim those with mocks; once a test no longer needs a real server, + # drop the marker and it will run in CI automatically. + - name: Run pytest + run: uv run --python ${{ matrix.python-version }} pytest -v -m "not network" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 06ee198..007d9d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "oshconnect" -version = "0.5.0a0" +version = "0.5.0a1" description = "Library for interfacing with OSH, helping guide visualization efforts, and providing a place to store configurations. Implements OGC CS API Part 3 (Pub/Sub) MQTT topic conventions including :data topics and resource event topics." readme = "README.md" authors = [ @@ -31,3 +31,6 @@ packages = {find = { where = ["src/"]}} [tool.pytest.ini_options] pythonpath = ["src"] +markers = [ + "network: test requires a live OSH server or external network endpoint (skipped by default in CI; see workflow `tests.yaml`).", +] diff --git a/tests/test_api_helper.py b/tests/test_api_helper.py deleted file mode 100644 index 8d4330d..0000000 --- a/tests/test_api_helper.py +++ /dev/null @@ -1,18 +0,0 @@ -from oshconnect.csapi4py import APIHelper - - -def test_url_generation(): - helper = APIHelper(server_url='localhost', port=8282, protocol='http', username='admin', password='admin') - expected_url = "http://localhost:8282/sensorhub/api" - url = helper.get_api_root_url() - assert url == expected_url - expected_url = "ws://localhost:8282/sensorhub/api" - url = helper.get_api_root_url(socket=True) - assert url == expected_url - helper.set_protocol('https') - expected_url = "https://localhost:8282/sensorhub/api" - url = helper.get_api_root_url() - assert url == expected_url - expected_url = "wss://localhost:8282/sensorhub/api" - url = helper.get_api_root_url(socket=True) - assert url == expected_url diff --git a/tests/test_imports.py b/tests/test_imports.py index 4e25a6e..9f7bbae 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -1,147 +1,55 @@ -# ============================================================================= -# Copyright (c) 2025 Botts Innovative Research Inc. -# Date: 2025/4/2 -# Author: Ian Patterson -# Contact Email: ian@botts-inc.com -# ============================================================================= -# -# Verifies that all public symbols are importable from the top-level package -# and from the csapi4py subpackage. Run with: -# uv run pytest tests/test_imports.py -# -# Requirements: the package must be installed in the environment first: -# uv sync (or) pip install -e . -# ============================================================================= - - -# --------------------------------------------------------------------------- -# Top-level package -# --------------------------------------------------------------------------- - -def test_core_resources_importable(): - from oshconnect import OSHConnect, Node, System, Datastream, ControlStream - assert OSHConnect is not None - assert Node is not None - assert System is not None - assert Datastream is not None - assert ControlStream is not None - - -def test_streaming_enums_importable(): - from oshconnect import StreamableModes, Status - assert StreamableModes is not None - assert Status is not None - - -def test_time_management_importable(): - from oshconnect import TimePeriod, TimeInstant, TemporalModes, TimeUtils - assert TimePeriod is not None - assert TimeInstant is not None - assert TemporalModes is not None - assert TimeUtils is not None - - -def test_resource_datamodels_importable(): - from oshconnect import ( - SystemResource, - DatastreamResource, - ControlStreamResource, - ObservationResource, - ) - assert SystemResource is not None - assert DatastreamResource is not None - assert ControlStreamResource is not None - assert ObservationResource is not None - - -def test_swe_schema_components_importable(): - from oshconnect import ( - DataRecordSchema, - VectorSchema, - QuantitySchema, - TimeSchema, - BooleanSchema, - CountSchema, - CategorySchema, - TextSchema, - QuantityRangeSchema, - TimeRangeSchema, - ) - for cls in (DataRecordSchema, VectorSchema, QuantitySchema, TimeSchema, - BooleanSchema, CountSchema, CategorySchema, TextSchema, - QuantityRangeSchema, TimeRangeSchema): - assert cls is not None - - -def test_schema_datamodels_importable(): - from oshconnect import SWEDatastreamRecordSchema, JSONCommandSchema - assert SWEDatastreamRecordSchema is not None - assert JSONCommandSchema is not None - - -def test_event_system_importable(): - from oshconnect import ( - EventHandler, - IEventListener, - DefaultEventTypes, - AtomicEventTypes, - Event, - EventBuilder, - ) - assert EventHandler is not None - assert IEventListener is not None - assert DefaultEventTypes is not None - assert AtomicEventTypes is not None - assert Event is not None - assert EventBuilder is not None - - -def test_csapi_constants_importable(): - from oshconnect import ObservationFormat, APIResourceTypes, ContentTypes - assert ObservationFormat is not None - assert APIResourceTypes is not None - assert ContentTypes is not None - - -def test_all_list_present_and_complete(): - import oshconnect - assert hasattr(oshconnect, "__all__") - assert len(oshconnect.__all__) > 0 - for name in oshconnect.__all__: - assert hasattr(oshconnect, name), f"__all__ lists '{name}' but it is not importable" - - -# --------------------------------------------------------------------------- -# csapi4py subpackage -# --------------------------------------------------------------------------- - -def test_csapi4py_constants_importable(): - from oshconnect.csapi4py import APIResourceTypes, ObservationFormat, ContentTypes, APITerms, SystemTypes - assert APIResourceTypes is not None - assert ObservationFormat is not None - assert ContentTypes is not None - assert APITerms is not None - assert SystemTypes is not None - - -def test_csapi4py_request_builder_importable(): - from oshconnect.csapi4py import ConnectedSystemsRequestBuilder, ConnectedSystemAPIRequest - assert ConnectedSystemsRequestBuilder is not None - assert ConnectedSystemAPIRequest is not None - - -def test_csapi4py_mqtt_importable(): - from oshconnect.csapi4py import MQTTCommClient - assert MQTTCommClient is not None - - -def test_csapi4py_api_helper_importable(): - from oshconnect.csapi4py import APIHelper - assert APIHelper is not None - - -def test_csapi4py_all_list_present_and_complete(): - import oshconnect.csapi4py as csapi4py - assert hasattr(csapi4py, "__all__") - for name in csapi4py.__all__: - assert hasattr(csapi4py, name), f"__all__ lists '{name}' but it is not importable" \ No newline at end of file +"""Public-API smoke tests: every name in `oshconnect.__all__` and +`oshconnect.csapi4py.__all__` is importable from its package, and the +re-exports we document users relying on actually resolve. +""" +import importlib + +import pytest + +# (package, names) — the documented public surface, grouped by concern. +EXPECTED_REEXPORTS = [ + ("oshconnect", ["OSHConnect", "Node", "System", "Datastream", "ControlStream"]), + ("oshconnect", ["StreamableModes", "Status"]), + ("oshconnect", ["TimePeriod", "TimeInstant", "TemporalModes", "TimeUtils"]), + ("oshconnect", ["SystemResource", "DatastreamResource", "ControlStreamResource", + "ObservationResource"]), + ("oshconnect", ["DataRecordSchema", "VectorSchema", "QuantitySchema", + "TimeSchema", "BooleanSchema", "CountSchema", "CategorySchema", + "TextSchema", "QuantityRangeSchema", "TimeRangeSchema"]), + ("oshconnect", ["SWEDatastreamRecordSchema", "JSONCommandSchema"]), + ("oshconnect", ["EventHandler", "IEventListener", "DefaultEventTypes", + "AtomicEventTypes", "Event", "EventBuilder"]), + ("oshconnect", ["ObservationFormat", "APIResourceTypes", "ContentTypes"]), + ("oshconnect.csapi4py", ["APIResourceTypes", "ObservationFormat", "ContentTypes", + "APITerms", "SystemTypes"]), + ("oshconnect.csapi4py", ["ConnectedSystemsRequestBuilder", + "ConnectedSystemAPIRequest"]), + ("oshconnect.csapi4py", ["MQTTCommClient"]), + ("oshconnect.csapi4py", ["APIHelper"]), +] + + +@pytest.mark.parametrize( + "package,names", + EXPECTED_REEXPORTS, + ids=[f"{pkg}:{','.join(names[:2])}{'…' if len(names) > 2 else ''}" + for pkg, names in EXPECTED_REEXPORTS], +) +def test_documented_reexports_resolve(package, names): + mod = importlib.import_module(package) + for name in names: + assert hasattr(mod, name), ( + f"{package} is expected to re-export {name!r} but does not" + ) + assert getattr(mod, name) is not None + + +@pytest.mark.parametrize("package", ["oshconnect", "oshconnect.csapi4py"]) +def test_all_list_present_and_complete(package): + mod = importlib.import_module(package) + assert hasattr(mod, "__all__"), f"{package} has no __all__" + assert len(mod.__all__) > 0, f"{package}.__all__ is empty" + for name in mod.__all__: + assert hasattr(mod, name), ( + f"{package}.__all__ lists {name!r} but it is not importable" + ) \ No newline at end of file diff --git a/tests/test_node.py b/tests/test_node.py new file mode 100644 index 0000000..e9369a9 --- /dev/null +++ b/tests/test_node.py @@ -0,0 +1,24 @@ +"""Node and APIHelper basics: URL construction and (de)serialization.""" +from oshconnect import Node +from oshconnect.csapi4py import APIHelper + + +def test_apihelper_url_generation(): + helper = APIHelper(server_url='localhost', port=8282, protocol='http', + username='admin', password='admin') + + assert helper.get_api_root_url() == "http://localhost:8282/sensorhub/api" + assert helper.get_api_root_url(socket=True) == "ws://localhost:8282/sensorhub/api" + + helper.set_protocol('https') + assert helper.get_api_root_url() == "https://localhost:8282/sensorhub/api" + assert helper.get_api_root_url(socket=True) == "wss://localhost:8282/sensorhub/api" + + +def test_node_password_round_trips_through_serialization(): + node = Node(protocol='http', address='localhost', port=8080, + username='user', password='pass') + serialized = node.serialize() + assert serialized['password'] == 'pass' + deserialized = Node.deserialize(serialized) + assert deserialized._api_helper.password == 'pass' \ No newline at end of file diff --git a/tests/test_oshconnect.py b/tests/test_oshconnect.py index 3ee042a..c8160c2 100644 --- a/tests/test_oshconnect.py +++ b/tests/test_oshconnect.py @@ -1,84 +1,61 @@ -# ============================================================================== -# Copyright (c) 2024 Botts Innovative Research, Inc. -# Date: 2024/5/28 -# Author: Ian Patterson -# Contact Email: ian@botts-inc.com -# ============================================================================== - -import sys -import os -import websockets - -from oshconnect import TimePeriod, TimeInstant -from src.oshconnect import OSHConnect, Node - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../src'))) - - -class TestOSHConnect: - TEST_PORT = 8282 - - def test_time_period(self): - tp = TimePeriod(start="2024-06-18T15:46:32Z", end="2024-06-18T20:00:00Z") - assert tp is not None - tps = tp.start - tpe = tp.end - assert isinstance(tps, TimeInstant) - assert isinstance(tpe, TimeInstant) - assert tps.epoch_time == TimeInstant.from_string("2024-06-18T15:46:32Z").epoch_time - assert tpe.epoch_time == TimeInstant.from_string("2024-06-18T20:00:00Z").epoch_time - - tp = TimePeriod(start="now", end="2099-06-18T20:00:00Z") - assert tp is not None - assert tp.start == "now" - assert tp.end.epoch_time == TimeInstant.from_string("2099-06-18T20:00:00Z").epoch_time - - tp = TimePeriod(start="2024-06-18T20:00:00Z", end="now") - assert tp is not None - assert tp.start.epoch_time == TimeInstant.from_string("2024-06-18T20:00:00Z").epoch_time - assert tp.end == "now" - - # tp = TimePeriod(start="now", end="now") - - def test_oshconnect_create(self): - app = OSHConnect(name="Test OSH Connect") - assert app is not None - assert app.get_name() == "Test OSH Connect" - - def test_oshconnect_add_node(self): - app = OSHConnect(name="Test OSH Connect") - node = Node(address="http://localhost", port=self.TEST_PORT, protocol="http", username="admin", - password="admin") - # node.add_basicauth("admin", "admin") - app.add_node(node) - assert len(app._nodes) == 1 - assert app._nodes[0] == node - - def test_find_systems(self): - app = OSHConnect(name="Test OSH Connect") - node = Node(address="localhost", port=self.TEST_PORT, username="admin", password="admin", protocol="http") - # node.add_basicauth("admin", "admin") - app.add_node(node) - app.discover_systems() - print(f'Found systems: {app._systems}') - # assert len(systems) == 1 - # assert systems[0] == node.get_api_endpoint() - - def test_oshconnect_find_datastreams(self): - app = OSHConnect(name="Test OSH Connect") - node = Node(address="localhost", port=self.TEST_PORT, username="admin", password="admin", protocol="http") - app.add_node(node) - app.discover_systems() - - app.discover_datastreams() - assert len(app._datastreams) > 0 - - async def test_obs_ws_stream(self): - ds_url = ( - "ws://localhost:8282/sensorhub/api/datastreams/038q16egp1t0/observations?resultTime=latest" - "/2026-01-01T12:00:00Z&f=application%2Fjson") - - # stream = requests.get(ds_url, stream=True, auth=('admin', 'admin')) - async with websockets.connect(ds_url, extra_headers={'Authorization': 'Basic YWRtaW46YWRtaW4='}) as stream: - async for message in stream: - print(message) +"""OSHConnect application object: construction, node attachment, live discovery. + +Tests marked `@pytest.mark.network` require a live OSH server at localhost:8282 +(e.g. FakeWeatherDriver). Skip in CI; see `.github/workflows/tests.yaml`. +""" +import pytest + +from oshconnect import Node, OSHConnect + +TEST_PORT = 8282 + + +def test_oshconnect_constructs_with_name(): + app = OSHConnect(name="Test OSH Connect") + assert app.get_name() == "Test OSH Connect" + + +def test_oshconnect_add_node_appends_to_nodes_list(): + app = OSHConnect(name="Test OSH Connect") + node = Node(address="http://localhost", port=TEST_PORT, protocol="http", + username="admin", password="admin") + app.add_node(node) + assert len(app._nodes) == 1 + assert app._nodes[0] is node + + +# --------------------------------------------------------------------------- +# Live-server tests (network-marked) +# --------------------------------------------------------------------------- + +@pytest.mark.network +def test_discover_systems_against_live_node(): + app = OSHConnect(name="Test OSH Connect") + node = Node(address="localhost", port=TEST_PORT, username="admin", + password="admin", protocol="http") + app.add_node(node) + app.discover_systems() + print(f'Found systems: {app._systems}') + + +@pytest.mark.network +def test_discover_datastreams_against_live_node(): + app = OSHConnect(name="Test OSH Connect") + node = Node(address="localhost", port=TEST_PORT, username="admin", + password="admin", protocol="http") + app.add_node(node) + app.discover_systems() + app.discover_datastreams() + assert len(app._datastreams) > 0 + + +@pytest.mark.network +def test_discover_then_get_datastreams_returns_list(): + app = OSHConnect("Test App") + node = Node(address="localhost", port=TEST_PORT, username="admin", + password="admin", protocol="http") + app.add_node(node) + app.discover_systems() + app.discover_datastreams() + datastreams = app.get_datastreams() + print(datastreams) \ No newline at end of file diff --git a/tests/test_serialization.py b/tests/test_serialization.py deleted file mode 100644 index 71c4530..0000000 --- a/tests/test_serialization.py +++ /dev/null @@ -1,10 +0,0 @@ -from oshconnect import Node - - -def test_node_password_serialization(): - node = Node(protocol='http', address='localhost', port=8080, username='user', password='pass') - serialized = node.serialize() - assert serialized['password'] == 'pass' - deserialized = Node.deserialize(serialized) - assert deserialized._api_helper.password == 'pass' - diff --git a/tests/test_streamable_resources.py b/tests/test_streamable_resources.py deleted file mode 100644 index f5fe182..0000000 --- a/tests/test_streamable_resources.py +++ /dev/null @@ -1,12 +0,0 @@ -from oshconnect import OSHConnect, Node - - -def test_streamble_observations(): - app = OSHConnect("Test App") - node = Node(address="localhost", port=8282, username="admin", password="admin", protocol="http") - app.add_node(node) - app.discover_systems() - app.discover_datastreams() - - datastreams = app.get_datastreams() - print(datastreams) \ No newline at end of file diff --git a/tests/test_swe_components.py b/tests/test_swe_components.py new file mode 100644 index 0000000..d1b159f --- /dev/null +++ b/tests/test_swe_components.py @@ -0,0 +1,573 @@ +"""SWE Common 3 component models: validators, structural rules, round-trip. + +Two sections: + + A. SoftNamedProperty `name` validation — `name` is required wherever a + component is bound (DataRecord.fields, DataChoice.items, Vector.coordinates, + DataArray/Matrix.elementType, and the root recordSchema/resultSchema of a + datastream/controlstream). Names must match NameToken + `^[A-Za-z][A-Za-z0-9_\\-]*$`. Standalone components do NOT require a name. + + B. Schema conformance — spec-required fields per leaf type, discriminator + routing, alias/snake_case parity, round-trip fidelity, Vector.coordinates + element-type restriction, DataRecord.fields minItems:1. + +Both sections are anchored against the canonical JSON schemas at: +https://github.com/opengeospatial/ogcapi-connected-systems/tree/master/swecommon/schemas/json +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from pydantic import TypeAdapter, ValidationError + +from oshconnect.schema_datamodels import ( + JSONCommandSchema, + JSONDatastreamRecordSchema, + SWEDatastreamRecordSchema, + SWEJSONCommandSchema, +) +from oshconnect.swe_components import ( + AnyComponent, + BooleanSchema, + CategoryRangeSchema, + CategorySchema, + CountRangeSchema, + CountSchema, + DataArraySchema, + DataChoiceSchema, + DataRecordSchema, + GeometrySchema, + MatrixSchema, + QuantityRangeSchema, + QuantitySchema, + TextSchema, + TimeRangeSchema, + TimeSchema, + VectorSchema, +) + +FIXTURES_DIR = Path(__file__).parent / "fixtures" +ANY_COMPONENT = TypeAdapter(AnyComponent) + + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +VALID_TIME_FIELD = { + "type": "Time", + "name": "time", + "label": "Sampling Time", + "definition": "http://www.opengis.net/def/property/OGC/0/SamplingTime", + "uom": {"href": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"}, +} +VALID_TEMP_FIELD = { + "type": "Quantity", + "name": "temperature", + "label": "Air Temperature", + "definition": "http://mmisw.org/ont/cf/parameter/air_temperature", + "uom": {"code": "Cel"}, +} + + +def _quantity_field(name: str = "x") -> dict: + return { + "type": "Quantity", + "name": name, + "label": "X", + "definition": "http://example.org/x", + "uom": {"code": "m"}, + } + + +# =========================================================================== +# A. SoftNamedProperty `name` validation +# =========================================================================== + +# --- A.1 standalone components don't need a name --------------------------- + +def test_quantity_standalone_no_name_ok(): + q = QuantitySchema(label="Air Temperature", + definition="http://example.org/temperature", + uom={"code": "Cel"}) + assert q.name is None + + +def test_vector_standalone_no_name_ok(): + v = VectorSchema( + label="Position", definition="http://example.org/position", + referenceFrame="http://example.org/frames/ENU", + coordinates=[ + QuantitySchema(name="x", label="X", + definition="http://example.org/x", uom={"code": "m"}), + QuantitySchema(name="y", label="Y", + definition="http://example.org/y", uom={"code": "m"}), + ], + ) + assert v.name is None + + +# --- A.2 fixtures: round-trip preserves names ------------------------------ + +def test_swejson_fixture_preserves_names_on_round_trip(): + raw = json.loads((FIXTURES_DIR / "fake_weather_schema_swejson.json").read_text()) + parsed = SWEDatastreamRecordSchema.model_validate(raw) + re_dumped = parsed.model_dump(mode="json", by_alias=True, exclude_none=True) + assert re_dumped["recordSchema"]["name"] == "weather" + assert {f["name"] for f in re_dumped["recordSchema"]["fields"]} == { + "time", "temperature", "pressure", "windSpeed", "windDirection" + } + + +def test_omjson_fixture_preserves_names_on_round_trip(): + raw = json.loads((FIXTURES_DIR / "fake_weather_schema_omjson.json").read_text()) + parsed = JSONDatastreamRecordSchema.model_validate(raw) + re_dumped = parsed.model_dump(mode="json", by_alias=True, exclude_none=True) + assert re_dumped["resultSchema"]["name"] == "weather" + + +# --- A.3 binding contexts require name on each child ----------------------- + +def test_record_with_named_fields_ok(): + DataRecordSchema(name="weather", + fields=[VALID_TIME_FIELD, VALID_TEMP_FIELD]) + + +def test_record_field_missing_name_raises(): + with pytest.raises(ValidationError, match="DataRecord.fields"): + DataRecordSchema(name="weather", fields=[ + {"type": "Quantity", "label": "X", + "definition": "http://example.org/x", "uom": {"code": "Cel"}}, + ]) + + +def test_choice_items_named_ok(): + DataChoiceSchema( + name="alt", + choiceValue=CategorySchema(name="picker", label="Picker", + definition="http://example.org/picker", + value="a"), + items=[_quantity_field("alt_a")], + ) + + +def test_choice_item_missing_name_raises(): + with pytest.raises(ValidationError, match="DataChoice.items"): + DataChoiceSchema( + name="alt", + choiceValue=CategorySchema(name="picker", label="Picker", + definition="http://example.org/picker", + value="a"), + items=[ + {"type": "Quantity", "label": "X", + "definition": "http://example.org/x", "uom": {"code": "m"}}, + ], + ) + + +def test_vector_coordinate_missing_name_raises(): + with pytest.raises(ValidationError, match="Vector.coordinates"): + VectorSchema( + label="Position", definition="http://example.org/position", + referenceFrame="http://example.org/frames/ENU", + coordinates=[ + {"type": "Quantity", "label": "X", + "definition": "http://example.org/x", "uom": {"code": "m"}}, + ], + ) + + +def test_dataarray_element_type_missing_name_raises(): + with pytest.raises(ValidationError, match="DataArray.elementType"): + DataArraySchema( + elementCount={"type": "Count", "name": "n", "label": "n", + "definition": "http://example.org/n"}, + elementType={"type": "Quantity", "label": "X", + "definition": "http://example.org/x", "uom": {"code": "m"}}, + encoding="JSONEncoding", + ) + + +def test_matrix_element_type_missing_name_raises(): + with pytest.raises(ValidationError, match="Matrix.elementType"): + MatrixSchema( + elementCount={"type": "Count", "name": "n", "label": "n", + "definition": "http://example.org/n"}, + elementType=[ + {"type": "Quantity", "label": "X", + "definition": "http://example.org/x", "uom": {"code": "m"}}, + ], + encoding="JSONEncoding", + ) + + +# --- A.4 datastream/controlstream wrappers: root requires name ------------- + +def test_swe_datastream_root_requires_name(): + with pytest.raises(ValidationError, match="SWEDatastreamRecordSchema.recordSchema"): + SWEDatastreamRecordSchema.model_validate({ + "obsFormat": "application/swe+json", + "recordSchema": { + "type": "DataRecord", + "definition": "urn:osh:data:weather", + "fields": [VALID_TIME_FIELD], + }, + }) + + +def test_json_datastream_optional_when_no_schemas_present(): + # Per CS API Part 2 §16.1.4, JSON form may use resultLink instead of + # inline schemas, so neither resultSchema nor parametersSchema is required. + JSONDatastreamRecordSchema.model_validate({"obsFormat": "application/json"}) + + +def test_json_datastream_result_schema_requires_name_when_present(): + with pytest.raises(ValidationError, match="JSONDatastreamRecordSchema.resultSchema"): + JSONDatastreamRecordSchema.model_validate({ + "obsFormat": "application/json", + "resultSchema": { + "type": "DataRecord", + "definition": "urn:osh:data:weather", + "fields": [VALID_TIME_FIELD], + }, + }) + + +def test_swe_command_schema_root_requires_name(): + with pytest.raises(ValidationError, match="SWEJSONCommandSchema.recordSchema"): + SWEJSONCommandSchema.model_validate({ + "commandFormat": "application/swe+json", + "encoding": {"type": "JSONEncoding"}, + "recordSchema": { + "type": "DataRecord", + "definition": "urn:osh:control:cmd", + "fields": [VALID_TIME_FIELD], + }, + }) + + +def test_json_command_schema_params_requires_name(): + with pytest.raises(ValidationError, match="JSONCommandSchema.parametersSchema"): + JSONCommandSchema.model_validate({ + "commandFormat": "application/json", + "parametersSchema": { + "type": "DataRecord", + "definition": "urn:osh:control:params", + "fields": [VALID_TIME_FIELD], + }, + }) + + +def test_nested_aggregate_in_record_fields_validated(): + # Aggregate-in-aggregate: a DataRecord inside another DataRecord's fields[]. + # The inner record must itself be named (it's the bound child); its own + # fields are validated by the inner record's validator independently. + DataRecordSchema(name="outer", fields=[ + {"type": "DataRecord", "name": "inner", "fields": [VALID_TIME_FIELD]}, + ]) + with pytest.raises(ValidationError, match="DataRecord.fields"): + DataRecordSchema(name="outer", fields=[ + {"type": "DataRecord", "fields": [VALID_TIME_FIELD]}, + ]) + + +# --- A.5 NameToken pattern ------------------------------------------------- + +@pytest.mark.parametrize("good_name", + ["a", "ab", "wind_speed", "wind-speed", "x1", "X_1-y"]) +def test_valid_name_tokens_accepted(good_name): + DataRecordSchema(name="root", fields=[_quantity_field(good_name)]) + + +@pytest.mark.parametrize("bad_name", + ["", "1leading", "with space", "with:colon", + "with.dot", "with/slash"]) +def test_invalid_name_tokens_rejected(bad_name): + with pytest.raises(ValidationError): + DataRecordSchema(name="root", fields=[_quantity_field(bad_name)]) + + +def test_swe_datastream_root_invalid_name_pattern_raises(): + with pytest.raises(ValidationError, match="NameToken"): + SWEDatastreamRecordSchema.model_validate({ + "obsFormat": "application/swe+json", + "recordSchema": { + "type": "DataRecord", + "name": "1bad-leading-digit", + "definition": "urn:osh:data:weather", + "fields": [VALID_TIME_FIELD], + }, + }) + + +# =========================================================================== +# B. Schema conformance +# =========================================================================== + +# --- B.1 spec `required` arrays per leaf type ------------------------------ +# Per the JSON schemas, required arrays per type: +# Quantity: [type, definition, label, uom] +# Boolean: [type, definition, label] +# Text: [type, definition, label] +# Vector: [type, definition, referenceFrame, label, coordinates] +# DataRecord:[type, fields] +# Geometry: [type, srs, definition, label] + + +def test_quantity_requires_uom(): + with pytest.raises(ValidationError, match="uom"): + QuantitySchema(label="X", definition="http://example.org/x") + + +def test_quantity_requires_label(): + with pytest.raises(ValidationError, match="label"): + QuantitySchema(definition="http://example.org/x", uom={"code": "m"}) + + +def test_quantity_requires_definition(): + with pytest.raises(ValidationError, match="definition"): + QuantitySchema(label="X", uom={"code": "m"}) + + +def test_boolean_requires_label_and_definition(): + with pytest.raises(ValidationError, match="label"): + BooleanSchema(definition="http://example.org/b") + with pytest.raises(ValidationError, match="definition"): + BooleanSchema(label="X") + + +def test_text_requires_label_and_definition(): + with pytest.raises(ValidationError, match="label"): + TextSchema(definition="http://example.org/t") + with pytest.raises(ValidationError, match="definition"): + TextSchema(label="X") + + +def test_vector_requires_label_definition_referenceframe_coordinates(): + base = dict( + label="V", definition="http://example.org/v", + referenceFrame="http://example.org/frames/ENU", + coordinates=[QuantitySchema(name="x", label="X", + definition="http://example.org/x", + uom={"code": "m"})], + ) + for missing in ("label", "definition", "referenceFrame", "coordinates"): + kwargs = {k: v for k, v in base.items() if k != missing} + with pytest.raises(ValidationError): + VectorSchema(**kwargs) + + +def test_datarecord_requires_fields(): + with pytest.raises(ValidationError, match="fields"): + DataRecordSchema(name="r") + + +def test_geometry_requires_srs_definition_label(): + base = dict(label="G", definition="http://example.org/g", + srs="http://www.opengis.net/def/crs/EPSG/0/4326") + for missing in ("label", "definition", "srs"): + kwargs = {k: v for k, v in base.items() if k != missing} + with pytest.raises(ValidationError): + GeometrySchema(**kwargs) + + +# --- B.2 discriminator routing --------------------------------------------- + +DISCRIMINATOR_CASES = [ + ("Boolean", + {"type": "Boolean", "label": "B", "definition": "http://example.org/b"}, + BooleanSchema), + ("Count", + {"type": "Count", "label": "C", "definition": "http://example.org/c"}, + CountSchema), + ("Quantity", + {"type": "Quantity", "label": "Q", "definition": "http://example.org/q", + "uom": {"code": "m"}}, + QuantitySchema), + ("Time", + {"type": "Time", "label": "T", "definition": "http://example.org/t", + "uom": {"href": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"}}, + TimeSchema), + ("Category", + {"type": "Category", "label": "Cat", "definition": "http://example.org/cat"}, + CategorySchema), + ("Text", + {"type": "Text", "label": "Tx", "definition": "http://example.org/tx"}, + TextSchema), + ("CountRange", + {"type": "CountRange", "label": "CR", "definition": "http://example.org/cr", + "uom": {"code": "1"}}, + CountRangeSchema), + ("QuantityRange", + {"type": "QuantityRange", "label": "QR", + "definition": "http://example.org/qr", "uom": {"code": "m"}}, + QuantityRangeSchema), + ("TimeRange", + {"type": "TimeRange", "label": "TR", "definition": "http://example.org/tr", + "uom": {"href": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"}}, + TimeRangeSchema), + ("CategoryRange", + {"type": "CategoryRange", "label": "CatR", + "definition": "http://example.org/catr"}, + CategoryRangeSchema), + ("DataRecord", + {"type": "DataRecord", "fields": [_quantity_field("a")]}, + DataRecordSchema), + ("Vector", + {"type": "Vector", "label": "V", "definition": "http://example.org/v", + "referenceFrame": "http://example.org/frames/ENU", + "coordinates": [_quantity_field("x")]}, + VectorSchema), + ("DataArray", + {"type": "DataArray", + "elementCount": {"type": "Count", "name": "n", "label": "n", + "definition": "http://example.org/n"}, + "elementType": _quantity_field("e"), + "encoding": "JSONEncoding"}, + DataArraySchema), + ("Matrix", + {"type": "Matrix", + "elementCount": {"type": "Count", "name": "n", "label": "n", + "definition": "http://example.org/n"}, + "elementType": [_quantity_field("e")], + "encoding": "JSONEncoding"}, + MatrixSchema), + ("DataChoice", + {"type": "DataChoice", + "choiceValue": {"type": "Category", "name": "pick", "label": "Pick", + "definition": "http://example.org/pick"}, + "items": [_quantity_field("a")]}, + DataChoiceSchema), + ("Geometry", + {"type": "Geometry", "label": "G", "definition": "http://example.org/g", + "srs": "http://www.opengis.net/def/crs/EPSG/0/4326"}, + GeometrySchema), +] + + +@pytest.mark.parametrize("type_literal,payload,expected_cls", + DISCRIMINATOR_CASES, + ids=[c[0] for c in DISCRIMINATOR_CASES]) +def test_anycomponent_discriminator_routes(type_literal, payload, expected_cls): + parsed = ANY_COMPONENT.validate_python(payload) + assert isinstance(parsed, expected_cls) + assert parsed.type == type_literal + + +def test_anycomponent_unknown_type_rejected(): + with pytest.raises(ValidationError): + ANY_COMPONENT.validate_python({"type": "NotAType", "label": "X"}) + + +# --- B.3 alias / snake_case parity ----------------------------------------- + +def test_quantity_axis_id_alias_parity(): + via_alias = QuantitySchema.model_validate({ + "name": "wd", "label": "Wind Direction", + "definition": "http://example.org/wd", + "axisID": "z", "uom": {"code": "deg"}, + }) + via_python = QuantitySchema( + name="wd", label="Wind Direction", + definition="http://example.org/wd", axis_id="z", uom={"code": "deg"}, + ) + assert via_alias.axis_id == "z" == via_python.axis_id + assert "axisID" in via_alias.model_dump(by_alias=True, exclude_none=True) + + +def test_vector_referenceframe_alias_parity(): + payload = { + "label": "V", "definition": "http://example.org/v", + "referenceFrame": "http://example.org/frames/ENU", + "coordinates": [_quantity_field("x")], + } + v = VectorSchema.model_validate(payload) + assert v.reference_frame == "http://example.org/frames/ENU" + dumped = v.model_dump(by_alias=True, exclude_none=True) + assert "referenceFrame" in dumped and "reference_frame" not in dumped + + +def test_swe_datastream_obsformat_recordschema_alias_parity(): + fixture = json.loads((FIXTURES_DIR / "fake_weather_schema_swejson.json").read_text()) + parsed_camel = SWEDatastreamRecordSchema.model_validate(fixture) + parsed_snake = SWEDatastreamRecordSchema( + obs_format=fixture["obsFormat"], + record_schema=fixture["recordSchema"], + ) + assert parsed_camel.obs_format == parsed_snake.obs_format + assert parsed_camel.record_schema.name == parsed_snake.record_schema.name + + +# --- B.4 round-trip fidelity ----------------------------------------------- + +@pytest.mark.parametrize("fixture_name,model_cls", [ + ("fake_weather_schema_swejson.json", SWEDatastreamRecordSchema), + ("fake_weather_schema_omjson.json", JSONDatastreamRecordSchema), +]) +def test_fixture_round_trip_stable(fixture_name, model_cls): + raw = json.loads((FIXTURES_DIR / fixture_name).read_text()) + first = model_cls.model_validate(raw) + first_dump = first.model_dump(mode="json", by_alias=True, exclude_none=True) + second = model_cls.model_validate(first_dump) + second_dump = second.model_dump(mode="json", by_alias=True, exclude_none=True) + assert first_dump == second_dump + + +def test_anycomponent_round_trip_through_typeadapter(): + # Stable-dump: parse → dump → reparse → dump, second dump matches first. + # We don't compare against the input dict because pydantic adds explicit + # default values (updatable=False / optional=False) to the dump. + payload = _quantity_field("temperature") + first = ANY_COMPONENT.validate_python(payload) + first_dump = ANY_COMPONENT.dump_python(first, mode="json", by_alias=True, + exclude_none=True) + second = ANY_COMPONENT.validate_python(first_dump) + second_dump = ANY_COMPONENT.dump_python(second, mode="json", by_alias=True, + exclude_none=True) + assert first_dump == second_dump + for k, v in payload.items(): + assert first_dump[k] == v + + +# --- B.5 Vector.coordinates element-type restriction ----------------------- + +def test_vector_rejects_boolean_in_coordinates(): + with pytest.raises(ValidationError): + VectorSchema.model_validate({ + "label": "V", "definition": "http://example.org/v", + "referenceFrame": "http://example.org/frames/ENU", + "coordinates": [{ + "type": "Boolean", "name": "flag", "label": "F", + "definition": "http://example.org/f", + }], + }) + + +def test_vector_rejects_record_in_coordinates(): + with pytest.raises(ValidationError): + VectorSchema.model_validate({ + "label": "V", "definition": "http://example.org/v", + "referenceFrame": "http://example.org/frames/ENU", + "coordinates": [{ + "type": "DataRecord", "name": "inner", + "fields": [_quantity_field("a")], + }], + }) + + +def test_vector_accepts_quantity_in_coordinates(): + VectorSchema.model_validate({ + "label": "V", "definition": "http://example.org/v", + "referenceFrame": "http://example.org/frames/ENU", + "coordinates": [_quantity_field("x")], + }) + + +# --- B.6 DataRecord.fields minItems: 1 ------------------------------------- + +def test_datarecord_empty_fields_rejected(): + with pytest.raises(ValidationError): + DataRecordSchema(name="r", fields=[]) \ No newline at end of file diff --git a/tests/test_swe_name_validation.py b/tests/test_swe_name_validation.py deleted file mode 100644 index a0c3cf0..0000000 --- a/tests/test_swe_name_validation.py +++ /dev/null @@ -1,394 +0,0 @@ -# ============================================================================= -# Copyright (c) 2026 Botts Innovative Research Inc. -# Author: Ian Patterson -# Contact Email: ian@botts-inc.com -# ============================================================================= -""" -SWE Common 3 SoftNamedProperty validation: a `name` is required wherever a -component is bound via SoftNamedProperty (DataRecord.fields, DataChoice.items, -Vector.coordinates, DataArray.elementType, Matrix.elementType, and the root -recordSchema/resultSchema of a datastream/controlstream — i.e., -DataStream.elementType). Names must match NameToken: ^[A-Za-z][A-Za-z0-9_\\-]*$. - -A standalone component (not bound) does NOT require a name; per the spec, -`name` is not a property of any data component itself. -""" -from __future__ import annotations - -import json -from pathlib import Path - -import pytest -from pydantic import ValidationError - -from src.oshconnect.schema_datamodels import ( - JSONDatastreamRecordSchema, - JSONCommandSchema, - SWEDatastreamRecordSchema, - SWEJSONCommandSchema, -) -from src.oshconnect.swe_components import ( - BooleanSchema, - CategorySchema, - CountSchema, - DataArraySchema, - DataChoiceSchema, - DataRecordSchema, - MatrixSchema, - QuantitySchema, - TimeSchema, - VectorSchema, -) - -FIXTURES_DIR = Path(__file__).parent / "fixtures" - -VALID_TIME_FIELD = { - "type": "Time", - "name": "time", - "label": "Sampling Time", - "definition": "http://www.opengis.net/def/property/OGC/0/SamplingTime", - "uom": {"href": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"}, -} -VALID_TEMP_FIELD = { - "type": "Quantity", - "name": "temperature", - "label": "Air Temperature", - "definition": "http://mmisw.org/ont/cf/parameter/air_temperature", - "uom": {"code": "Cel"}, -} -INVALID_NAMES = ["", "1bad", "with space", "has:colon", "has/slash", "has.dot"] - - -# --------------------------------------------------------------------------- -# Standalone components do not need a name (positive cases) -# --------------------------------------------------------------------------- - -def test_quantity_standalone_no_name_ok(): - q = QuantitySchema( - label="Air Temperature", - definition="http://example.org/temperature", - uom={"code": "Cel"}, - ) - assert q.name is None - - -def test_vector_standalone_no_name_ok(): - v = VectorSchema( - label="Position", - definition="http://example.org/position", - referenceFrame="http://example.org/frames/ENU", - coordinates=[ - QuantitySchema( - name="x", label="X", definition="http://example.org/x", uom={"code": "m"} - ), - QuantitySchema( - name="y", label="Y", definition="http://example.org/y", uom={"code": "m"} - ), - ], - ) - assert v.name is None - - -def test_existing_swejson_fixture_round_trips(): - raw = json.loads((FIXTURES_DIR / "fake_weather_schema_swejson.json").read_text()) - parsed = SWEDatastreamRecordSchema.model_validate(raw) - re_dumped = parsed.model_dump(mode="json", by_alias=True, exclude_none=True) - assert re_dumped["recordSchema"]["name"] == "weather" - assert {f["name"] for f in re_dumped["recordSchema"]["fields"]} == { - "time", "temperature", "pressure", "windSpeed", "windDirection" - } - - -def test_existing_omjson_fixture_round_trips(): - raw = json.loads((FIXTURES_DIR / "fake_weather_schema_omjson.json").read_text()) - parsed = JSONDatastreamRecordSchema.model_validate(raw) - re_dumped = parsed.model_dump(mode="json", by_alias=True, exclude_none=True) - assert re_dumped["resultSchema"]["name"] == "weather" - - -# --------------------------------------------------------------------------- -# DataRecord.fields[*] requires name (negative cases) -# --------------------------------------------------------------------------- - -def test_record_with_named_fields_ok(): - DataRecordSchema( - name="weather", - fields=[VALID_TIME_FIELD, VALID_TEMP_FIELD], - ) - - -def test_record_field_missing_name_raises(): - with pytest.raises(ValidationError, match="DataRecord.fields"): - DataRecordSchema( - name="weather", - fields=[ - { - "type": "Quantity", - "label": "Air Temperature", - "definition": "http://example.org/temp", - "uom": {"code": "Cel"}, - } - ], - ) - - -@pytest.mark.parametrize("bad_name", INVALID_NAMES) -def test_record_field_invalid_name_raises(bad_name): - with pytest.raises(ValidationError): - DataRecordSchema( - name="weather", - fields=[ - { - "type": "Quantity", - "name": bad_name, - "label": "Air Temperature", - "definition": "http://example.org/temp", - "uom": {"code": "Cel"}, - } - ], - ) - - -# --------------------------------------------------------------------------- -# DataChoice.items[*] requires name -# --------------------------------------------------------------------------- - -def test_choice_items_named_ok(): - DataChoiceSchema( - name="alt", - choiceValue=CategorySchema( - name="picker", - label="Picker", - definition="http://example.org/picker", - value="a", - ), - items=[ - { - "type": "Quantity", - "name": "alt_a", - "label": "Option A", - "definition": "http://example.org/a", - "uom": {"code": "m"}, - } - ], - ) - - -def test_choice_item_missing_name_raises(): - with pytest.raises(ValidationError, match="DataChoice.items"): - DataChoiceSchema( - name="alt", - choiceValue=CategorySchema( - name="picker", - label="Picker", - definition="http://example.org/picker", - value="a", - ), - items=[ - { - "type": "Quantity", - "label": "Option A", - "definition": "http://example.org/a", - "uom": {"code": "m"}, - } - ], - ) - - -# --------------------------------------------------------------------------- -# Vector.coordinates[*] requires name -# --------------------------------------------------------------------------- - -def test_vector_coordinate_missing_name_raises(): - with pytest.raises(ValidationError, match="Vector.coordinates"): - VectorSchema( - label="Position", - definition="http://example.org/position", - referenceFrame="http://example.org/frames/ENU", - coordinates=[ - { - "type": "Quantity", - "label": "X", - "definition": "http://example.org/x", - "uom": {"code": "m"}, - } - ], - ) - - -# --------------------------------------------------------------------------- -# DataArray.elementType requires name -# --------------------------------------------------------------------------- - -def test_dataarray_element_type_missing_name_raises(): - with pytest.raises(ValidationError, match="DataArray.elementType"): - DataArraySchema( - elementCount={"type": "Count", "name": "n", "label": "n", - "definition": "http://example.org/n"}, - elementType={ - "type": "Quantity", - "label": "X", - "definition": "http://example.org/x", - "uom": {"code": "m"}, - }, - encoding="JSONEncoding", - ) - - -# --------------------------------------------------------------------------- -# Matrix.elementType[*] requires name -# --------------------------------------------------------------------------- - -def test_matrix_element_type_missing_name_raises(): - with pytest.raises(ValidationError, match="Matrix.elementType"): - MatrixSchema( - elementCount={"type": "Count", "name": "n", "label": "n", - "definition": "http://example.org/n"}, - elementType=[ - { - "type": "Quantity", - "label": "X", - "definition": "http://example.org/x", - "uom": {"code": "m"}, - } - ], - encoding="JSONEncoding", - ) - - -# --------------------------------------------------------------------------- -# Datastream/Controlstream wrappers: root requires name -# --------------------------------------------------------------------------- - -def test_swe_datastream_root_requires_name(): - with pytest.raises(ValidationError, match="SWEDatastreamRecordSchema.recordSchema"): - SWEDatastreamRecordSchema.model_validate({ - "obsFormat": "application/swe+json", - "recordSchema": { - "type": "DataRecord", - "definition": "urn:osh:data:weather", - "fields": [VALID_TIME_FIELD], - }, - }) - - -def test_swe_datastream_root_invalid_name_pattern_raises(): - with pytest.raises(ValidationError, match="NameToken"): - SWEDatastreamRecordSchema.model_validate({ - "obsFormat": "application/swe+json", - "recordSchema": { - "type": "DataRecord", - "name": "1bad-leading-digit", - "definition": "urn:osh:data:weather", - "fields": [VALID_TIME_FIELD], - }, - }) - - -def test_json_datastream_optional_when_no_schemas_present(): - # Per CS API Part 2 §16.1.4, JSON form may use resultLink instead of - # inline schemas, so neither resultSchema nor parametersSchema is required. - JSONDatastreamRecordSchema.model_validate({ - "obsFormat": "application/json", - }) - - -def test_json_datastream_result_schema_requires_name_when_present(): - with pytest.raises(ValidationError, match="JSONDatastreamRecordSchema.resultSchema"): - JSONDatastreamRecordSchema.model_validate({ - "obsFormat": "application/json", - "resultSchema": { - "type": "DataRecord", - "definition": "urn:osh:data:weather", - "fields": [VALID_TIME_FIELD], - }, - }) - - -def test_swe_command_schema_root_requires_name(): - with pytest.raises(ValidationError, match="SWEJSONCommandSchema.recordSchema"): - SWEJSONCommandSchema.model_validate({ - "commandFormat": "application/swe+json", - "encoding": {"type": "JSONEncoding"}, - "recordSchema": { - "type": "DataRecord", - "definition": "urn:osh:control:cmd", - "fields": [VALID_TIME_FIELD], - }, - }) - - -def test_json_command_schema_params_requires_name(): - with pytest.raises(ValidationError, match="JSONCommandSchema.parametersSchema"): - JSONCommandSchema.model_validate({ - "commandFormat": "application/json", - "parametersSchema": { - "type": "DataRecord", - "definition": "urn:osh:control:params", - "fields": [VALID_TIME_FIELD], - }, - }) - - -# --------------------------------------------------------------------------- -# NameToken pattern coverage -# --------------------------------------------------------------------------- - -def test_nested_aggregate_in_record_fields_validated(): - # Aggregate-in-aggregate: a DataRecord inside another DataRecord's fields[]. The - # inner record must itself be named (it's the bound child); its own fields are then - # validated by the inner record's validator independently. - DataRecordSchema( - name="outer", - fields=[ - { - "type": "DataRecord", - "name": "inner", - "fields": [VALID_TIME_FIELD], - } - ], - ) - # Inner record present but unnamed → outer's validator catches it. - with pytest.raises(ValidationError, match="DataRecord.fields"): - DataRecordSchema( - name="outer", - fields=[ - { - "type": "DataRecord", - "fields": [VALID_TIME_FIELD], - } - ], - ) - - -@pytest.mark.parametrize("good_name", ["a", "ab", "wind_speed", "wind-speed", "x1", "X_1-y"]) -def test_valid_name_tokens_accepted(good_name): - DataRecordSchema( - name="root", - fields=[ - { - "type": "Quantity", - "name": good_name, - "label": "X", - "definition": "http://example.org/x", - "uom": {"code": "m"}, - } - ], - ) - - -@pytest.mark.parametrize("bad_name", ["1leading", "with space", "with:colon", "with.dot", "with/slash"]) -def test_invalid_name_tokens_rejected(bad_name): - with pytest.raises(ValidationError, match="NameToken"): - DataRecordSchema( - name="root", - fields=[ - { - "type": "Quantity", - "name": bad_name, - "label": "X", - "definition": "http://example.org/x", - "uom": {"code": "m"}, - } - ], - ) diff --git a/tests/test_swe_schema_validation.py b/tests/test_swe_schema_validation.py deleted file mode 100644 index 738f01f..0000000 --- a/tests/test_swe_schema_validation.py +++ /dev/null @@ -1,371 +0,0 @@ -# ============================================================================= -# Copyright (c) 2026 Botts Innovative Research Inc. -# Author: Ian Patterson -# Contact Email: ian@botts-inc.com -# ============================================================================= -""" -SWE Common 3 schema-conformance tests beyond the SoftNamedProperty `name` rule: - -1. Spec `required` arrays per leaf component type (Quantity needs uom, Vector - needs referenceFrame, etc.) — guard against accidental Field(...) → Field(None) - regressions. -2. Discriminator routing: AnyComponent.model_validate dispatches by `type` to - the correct concrete class, and rejects unknown types. -3. Alias / field-name parity: both camelCase wire-format and snake_case Python - names parse to identical models. -4. Round-trip fidelity: parse → dump(by_alias, exclude_none) → re-parse, deep equal. -5. Vector.coordinates element-type restriction (Count/Quantity/Time only). -6. DataRecord.fields minItems: 1 (per DataRecord.json). -""" -from __future__ import annotations - -import json -from pathlib import Path - -import pytest -from pydantic import TypeAdapter, ValidationError - -from src.oshconnect.schema_datamodels import ( - JSONDatastreamRecordSchema, - SWEDatastreamRecordSchema, -) -from src.oshconnect.swe_components import ( - AnyComponent, - BooleanSchema, - CategoryRangeSchema, - CategorySchema, - CountRangeSchema, - CountSchema, - DataArraySchema, - DataChoiceSchema, - DataRecordSchema, - GeometrySchema, - MatrixSchema, - QuantityRangeSchema, - QuantitySchema, - TextSchema, - TimeRangeSchema, - TimeSchema, - VectorSchema, -) - -FIXTURES_DIR = Path(__file__).parent / "fixtures" -ANY_COMPONENT = TypeAdapter(AnyComponent) - - -def _quantity_field(name: str = "x") -> dict: - return { - "type": "Quantity", - "name": name, - "label": "X", - "definition": "http://example.org/x", - "uom": {"code": "m"}, - } - - -# --------------------------------------------------------------------------- -# 1. Spec `required` arrays per leaf component type -# --------------------------------------------------------------------------- -# Per JSON schemas at: -# https://github.com/opengeospatial/ogcapi-connected-systems/tree/master/swecommon/schemas/json -# Required arrays: -# Quantity: [type, definition, label, uom] -# Boolean: [type, definition, label] -# Text: [type, definition, label] (inherited Boolean shape) -# Vector: [type, definition, referenceFrame, label, coordinates] -# DataRecord:[type, fields] -# Geometry: [type, srs, definition, label] - - -def test_quantity_requires_uom(): - with pytest.raises(ValidationError, match="uom"): - QuantitySchema(label="X", definition="http://example.org/x") - - -def test_quantity_requires_label(): - with pytest.raises(ValidationError, match="label"): - QuantitySchema(definition="http://example.org/x", uom={"code": "m"}) - - -def test_quantity_requires_definition(): - with pytest.raises(ValidationError, match="definition"): - QuantitySchema(label="X", uom={"code": "m"}) - - -def test_boolean_requires_label_and_definition(): - with pytest.raises(ValidationError, match="label"): - BooleanSchema(definition="http://example.org/b") - with pytest.raises(ValidationError, match="definition"): - BooleanSchema(label="X") - - -def test_text_requires_label_and_definition(): - with pytest.raises(ValidationError, match="label"): - TextSchema(definition="http://example.org/t") - with pytest.raises(ValidationError, match="definition"): - TextSchema(label="X") - - -def test_vector_requires_label_definition_referenceframe_coordinates(): - base = dict( - label="V", - definition="http://example.org/v", - referenceFrame="http://example.org/frames/ENU", - coordinates=[ - QuantitySchema(name="x", label="X", - definition="http://example.org/x", uom={"code": "m"}), - ], - ) - for missing in ("label", "definition", "referenceFrame", "coordinates"): - kwargs = {k: v for k, v in base.items() if k != missing} - with pytest.raises(ValidationError): - VectorSchema(**kwargs) - - -def test_datarecord_requires_fields(): - with pytest.raises(ValidationError, match="fields"): - DataRecordSchema(name="r") - - -def test_geometry_requires_srs_definition_label(): - base = dict( - label="G", - definition="http://example.org/g", - srs="http://www.opengis.net/def/crs/EPSG/0/4326", - ) - for missing in ("label", "definition", "srs"): - kwargs = {k: v for k, v in base.items() if k != missing} - with pytest.raises(ValidationError): - GeometrySchema(**kwargs) - - -# --------------------------------------------------------------------------- -# 2. Discriminator routing -# --------------------------------------------------------------------------- - -DISCRIMINATOR_CASES = [ - # (type literal, minimal-valid dict, expected pydantic class) - ("Boolean", - {"type": "Boolean", "label": "B", "definition": "http://example.org/b"}, - BooleanSchema), - ("Count", - {"type": "Count", "label": "C", "definition": "http://example.org/c"}, - CountSchema), - ("Quantity", - {"type": "Quantity", "label": "Q", "definition": "http://example.org/q", - "uom": {"code": "m"}}, - QuantitySchema), - ("Time", - {"type": "Time", "label": "T", "definition": "http://example.org/t", - "uom": {"href": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"}}, - TimeSchema), - ("Category", - {"type": "Category", "label": "Cat", "definition": "http://example.org/cat"}, - CategorySchema), - ("Text", - {"type": "Text", "label": "Tx", "definition": "http://example.org/tx"}, - TextSchema), - ("CountRange", - {"type": "CountRange", "label": "CR", "definition": "http://example.org/cr", - "uom": {"code": "1"}}, - CountRangeSchema), - ("QuantityRange", - {"type": "QuantityRange", "label": "QR", "definition": "http://example.org/qr", - "uom": {"code": "m"}}, - QuantityRangeSchema), - ("TimeRange", - {"type": "TimeRange", "label": "TR", "definition": "http://example.org/tr", - "uom": {"href": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"}}, - TimeRangeSchema), - ("CategoryRange", - {"type": "CategoryRange", "label": "CatR", - "definition": "http://example.org/catr"}, - CategoryRangeSchema), - ("DataRecord", - {"type": "DataRecord", "fields": [_quantity_field("a")]}, - DataRecordSchema), - ("Vector", - {"type": "Vector", "label": "V", "definition": "http://example.org/v", - "referenceFrame": "http://example.org/frames/ENU", - "coordinates": [_quantity_field("x")]}, - VectorSchema), - ("DataArray", - {"type": "DataArray", - "elementCount": {"type": "Count", "name": "n", "label": "n", - "definition": "http://example.org/n"}, - "elementType": _quantity_field("e"), - "encoding": "JSONEncoding"}, - DataArraySchema), - ("Matrix", - {"type": "Matrix", - "elementCount": {"type": "Count", "name": "n", "label": "n", - "definition": "http://example.org/n"}, - "elementType": [_quantity_field("e")], - "encoding": "JSONEncoding"}, - MatrixSchema), - ("DataChoice", - {"type": "DataChoice", - "choiceValue": {"type": "Category", "name": "pick", "label": "Pick", - "definition": "http://example.org/pick"}, - "items": [_quantity_field("a")]}, - DataChoiceSchema), - ("Geometry", - {"type": "Geometry", "label": "G", "definition": "http://example.org/g", - "srs": "http://www.opengis.net/def/crs/EPSG/0/4326"}, - GeometrySchema), -] - - -@pytest.mark.parametrize( - "type_literal,payload,expected_cls", - DISCRIMINATOR_CASES, - ids=[c[0] for c in DISCRIMINATOR_CASES], -) -def test_anycomponent_discriminator_routes(type_literal, payload, expected_cls): - parsed = ANY_COMPONENT.validate_python(payload) - assert isinstance(parsed, expected_cls) - assert parsed.type == type_literal - - -def test_anycomponent_unknown_type_rejected(): - with pytest.raises(ValidationError): - ANY_COMPONENT.validate_python({"type": "NotAType", "label": "X"}) - - -# --------------------------------------------------------------------------- -# 3. Alias / field-name parity -# --------------------------------------------------------------------------- -# OSH wire format is camelCase; our pydantic fields are snake_case with alias= -# entries. Confirm both inputs produce equivalent models, and dumping by_alias -# yields the camelCase form. - - -def test_quantity_axis_id_alias_parity(): - via_alias = QuantitySchema.model_validate({ - "name": "wd", - "label": "Wind Direction", - "definition": "http://example.org/wd", - "axisID": "z", - "uom": {"code": "deg"}, - }) - via_python = QuantitySchema( - name="wd", label="Wind Direction", - definition="http://example.org/wd", axis_id="z", uom={"code": "deg"}, - ) - assert via_alias.axis_id == "z" == via_python.axis_id - assert "axisID" in via_alias.model_dump(by_alias=True, exclude_none=True) - - -def test_vector_referenceframe_alias_parity(): - payload = { - "label": "V", "definition": "http://example.org/v", - "referenceFrame": "http://example.org/frames/ENU", - "coordinates": [_quantity_field("x")], - } - v = VectorSchema.model_validate(payload) - assert v.reference_frame == "http://example.org/frames/ENU" - dumped = v.model_dump(by_alias=True, exclude_none=True) - assert "referenceFrame" in dumped - assert "reference_frame" not in dumped - - -def test_swe_datastream_obsformat_recordschema_alias_parity(): - fixture = json.loads((FIXTURES_DIR / "fake_weather_schema_swejson.json").read_text()) - parsed_camel = SWEDatastreamRecordSchema.model_validate(fixture) - parsed_snake = SWEDatastreamRecordSchema( - obs_format=fixture["obsFormat"], - record_schema=fixture["recordSchema"], - ) - assert parsed_camel.obs_format == parsed_snake.obs_format - assert parsed_camel.record_schema.name == parsed_snake.record_schema.name - - -# --------------------------------------------------------------------------- -# 4. Round-trip fidelity -# --------------------------------------------------------------------------- -# Strongest single guard against serializer regressions: load a fixture, -# dump it, re-parse the dump, and confirm the second dump matches the first. - - -@pytest.mark.parametrize( - "fixture_name,model_cls", - [ - ("fake_weather_schema_swejson.json", SWEDatastreamRecordSchema), - ("fake_weather_schema_omjson.json", JSONDatastreamRecordSchema), - ], -) -def test_fixture_round_trip_stable(fixture_name, model_cls): - raw = json.loads((FIXTURES_DIR / fixture_name).read_text()) - first = model_cls.model_validate(raw) - first_dump = first.model_dump(mode="json", by_alias=True, exclude_none=True) - second = model_cls.model_validate(first_dump) - second_dump = second.model_dump(mode="json", by_alias=True, exclude_none=True) - assert first_dump == second_dump - - -def test_anycomponent_round_trip_through_typeadapter(): - # Stable-dump: parse → dump → reparse → dump, second dump matches first. - # (We don't compare against the input dict because pydantic adds explicit - # default values like updatable=False / optional=False to the dump.) - payload = _quantity_field("temperature") - first = ANY_COMPONENT.validate_python(payload) - first_dump = ANY_COMPONENT.dump_python(first, mode="json", by_alias=True, - exclude_none=True) - second = ANY_COMPONENT.validate_python(first_dump) - second_dump = ANY_COMPONENT.dump_python(second, mode="json", by_alias=True, - exclude_none=True) - assert first_dump == second_dump - # Sanity: input keys are all preserved in the dump. - for k, v in payload.items(): - assert first_dump[k] == v - - -# --------------------------------------------------------------------------- -# 5. Vector.coordinates element-type restriction -# --------------------------------------------------------------------------- -# Vector.json: coordinates items oneOf [Count, Quantity, Time]. - - -def test_vector_rejects_boolean_in_coordinates(): - with pytest.raises(ValidationError): - VectorSchema.model_validate({ - "label": "V", "definition": "http://example.org/v", - "referenceFrame": "http://example.org/frames/ENU", - "coordinates": [{ - "type": "Boolean", "name": "flag", "label": "F", - "definition": "http://example.org/f", - }], - }) - - -def test_vector_rejects_record_in_coordinates(): - with pytest.raises(ValidationError): - VectorSchema.model_validate({ - "label": "V", "definition": "http://example.org/v", - "referenceFrame": "http://example.org/frames/ENU", - "coordinates": [{ - "type": "DataRecord", "name": "inner", - "fields": [_quantity_field("a")], - }], - }) - - -def test_vector_accepts_count_quantity_time_in_coordinates(): - VectorSchema.model_validate({ - "label": "V", "definition": "http://example.org/v", - "referenceFrame": "http://example.org/frames/ENU", - "coordinates": [ - {"type": "Quantity", "name": "x", "label": "X", - "definition": "http://example.org/x", "uom": {"code": "m"}}, - ], - }) - - -# --------------------------------------------------------------------------- -# 6. DataRecord.fields minItems: 1 -# --------------------------------------------------------------------------- - - -def test_datarecord_empty_fields_rejected(): - with pytest.raises(ValidationError): - DataRecordSchema(name="r", fields=[]) \ No newline at end of file diff --git a/tests/test_time_management.py b/tests/test_time_management.py new file mode 100644 index 0000000..b16cf16 --- /dev/null +++ b/tests/test_time_management.py @@ -0,0 +1,22 @@ +"""TimePeriod / TimeInstant primitives from oshconnect.timemanagement.""" +from oshconnect import TimeInstant, TimePeriod + + +def test_time_period_with_iso_strings_resolves_to_time_instants(): + tp = TimePeriod(start="2024-06-18T15:46:32Z", end="2024-06-18T20:00:00Z") + assert isinstance(tp.start, TimeInstant) + assert isinstance(tp.end, TimeInstant) + assert tp.start.epoch_time == TimeInstant.from_string("2024-06-18T15:46:32Z").epoch_time + assert tp.end.epoch_time == TimeInstant.from_string("2024-06-18T20:00:00Z").epoch_time + + +def test_time_period_now_sentinel_preserved_at_start(): + tp = TimePeriod(start="now", end="2099-06-18T20:00:00Z") + assert tp.start == "now" + assert tp.end.epoch_time == TimeInstant.from_string("2099-06-18T20:00:00Z").epoch_time + + +def test_time_period_now_sentinel_preserved_at_end(): + tp = TimePeriod(start="2024-06-18T20:00:00Z", end="now") + assert tp.start.epoch_time == TimeInstant.from_string("2024-06-18T20:00:00Z").epoch_time + assert tp.end == "now" \ No newline at end of file diff --git a/uv.lock b/uv.lock index 5c55e6b..7cd5de5 100644 --- a/uv.lock +++ b/uv.lock @@ -619,7 +619,7 @@ wheels = [ [[package]] name = "oshconnect" -version = "0.5.0a0" +version = "0.5.0a1" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, From c79d5d29ac36d3a994a8f362347a81a1f9bb1e16 Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 1 May 2026 20:56:20 -0500 Subject: [PATCH 2/3] add doc coverage --- .github/workflows/tests.yaml | 17 ++++- README.md | 54 ++++++++++++++ pyproject.toml | 40 +++++++++++ uv.lock | 136 +++++++++++++++++++++++++++++++++++ 4 files changed, 245 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 4083f73..8989ea8 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -34,5 +34,18 @@ jobs: # They're tagged `@pytest.mark.network` and skipped here. The plan is # to shim those with mocks; once a test no longer needs a real server, # drop the marker and it will run in CI automatically. - - name: Run pytest - run: uv run --python ${{ matrix.python-version }} pytest -v -m "not network" \ No newline at end of file + - name: Run pytest with coverage + run: | + uv run --python ${{ matrix.python-version }} pytest -v \ + -m "not network" \ + --cov --cov-report=term --cov-report=xml + + # Keep coverage.xml around so a later badge/Codecov upload step can use it. + - name: Upload coverage report artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ matrix.python-version }} + path: coverage.xml + if-no-files-found: warn + retention-days: 7 \ No newline at end of file diff --git a/README.md b/README.md index 0a24c55..36aa365 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,60 @@ Links: * [Architecture Doc](https://docs.google.com/document/d/1pIaeQw0ocU6ApNgqTVRZuSwjJAbhCcmweMq6RiVYEic/edit?usp=sharing) * [UML Diagram](https://drive.google.com/file/d/1FVrnYiuAR8ykqfOUa1NuoMyZ1abXzMPw/view?usp=drive_link) +## Running Tests + +```bash +uv sync # install dev deps (incl. pytest, pytest-cov) +uv run pytest # full suite (skips network-marked tests if you add `-m "not network"`) +uv run pytest tests/test_swe_components.py -v # one file, verbose +uv run pytest -k name_token # one keyword +``` + +Tests that need a live OSH server (e.g. `localhost:8282` running +FakeWeatherDriver) are tagged `@pytest.mark.network`. CI skips them; locally +you can include or exclude them: + +```bash +uv run pytest -m "not network" # what CI runs +uv run pytest -m network # only the live-server tests +``` + +## Test Coverage + +Coverage is opt-in via [`pytest-cov`](https://pytest-cov.readthedocs.io/). The +default `pytest` run is fast; add `--cov` when you want a report. + +```bash +uv run pytest --cov # terminal summary + missing lines +uv run pytest --cov --cov-report=html # HTML report at htmlcov/index.html +uv run pytest --cov --cov-report=xml # coverage.xml (CI / Codecov-ready) +``` + +Configuration lives in `pyproject.toml` under `[tool.coverage.*]` — branch +coverage is on, source is scoped to `src/oshconnect`, and obvious dead lines +(`if TYPE_CHECKING:`, `raise NotImplementedError`, etc.) are excluded. + +CI (`.github/workflows/tests.yaml`) runs the suite with `--cov` on every push +across Python 3.12 / 3.13 / 3.14 and uploads `coverage.xml` as a workflow +artifact (downloadable from the run page). + +## Documentation Coverage + +[`interrogate`](https://interrogate.readthedocs.io/) reports what fraction of +public modules / classes / functions / methods carry a docstring (presence +only, it doesn't check style). It's purely informational right now; there's +no CI gate. Configuration lives in `pyproject.toml` under `[tool.interrogate]` +(`__init__`, dunder, private, and property/setter members are skipped). + +```bash +uv run interrogate src/oshconnect # one-line summary +uv run interrogate -v src/oshconnect # per-file table +uv run interrogate -vv src/oshconnect # per-symbol (shows which symbols are missing) +``` + +Once we agree on a baseline, raise `[tool.interrogate].fail-under` from `0` so +new code without docstrings starts failing locally and in CI. + ## Generating the Docs The documentation is built with [MkDocs](https://www.mkdocs.org/) using the diff --git a/pyproject.toml b/pyproject.toml index 007d9d7..13f12ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,8 @@ dependencies = [ dev = [ "flake8>=7.2.0", "pytest>=8.3.5", + "pytest-cov>=5.0.0", + "interrogate>=1.7.0", "sphinx>=7.4.7", "sphinx-rtd-theme>=2.0.0", "mkdocs-material>=9.5.0", @@ -34,3 +36,41 @@ pythonpath = ["src"] markers = [ "network: test requires a live OSH server or external network endpoint (skipped by default in CI; see workflow `tests.yaml`).", ] + +# Coverage is opt-in (run with `pytest --cov`) so the default `pytest` run stays fast. +# `--cov` with no argument picks up the source paths configured below. + +[tool.coverage.run] +source = ["src/oshconnect"] +branch = true + +[tool.coverage.report] +show_missing = true +skip_covered = false +precision = 2 +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "if TYPE_CHECKING:", + "@(abc\\.)?abstractmethod", + "if __name__ == .__main__.:", +] + +[tool.coverage.html] +directory = "htmlcov" + +[tool.coverage.xml] +output = "coverage.xml" + +# Docstring presence (not style). Run with `uv run interrogate -v src/oshconnect`. +[tool.interrogate] +ignore-init-method = true # constructors covered by class docstring +ignore-init-module = true # don't require docstrings on bare __init__.py +ignore-magic = true # skip dunder methods (__repr__, __eq__, etc.) +ignore-private = true # skip _name and __name (non-dunder) members +ignore-property-decorators = true +ignore-nested-functions = true +ignore-setters = true +fail-under = 0 # report-only for now; raise once a baseline is set +exclude = ["tests", "docs", "build", ".venv", "scripts"] +verbose = 2 # 0=summary, 1=per-file, 2=per-symbol diff --git a/uv.lock b/uv.lock index 7cd5de5..e1cc0e8 100644 --- a/uv.lock +++ b/uv.lock @@ -189,6 +189,90 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + [[package]] name = "docutils" version = "0.20.1" @@ -320,6 +404,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] +[[package]] +name = "interrogate" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "click" }, + { name = "colorama" }, + { name = "py" }, + { name = "tabulate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/22/74f7fcc96280eea46cf2bcbfa1354ac31de0e60a4be6f7966f12cef20893/interrogate-1.7.0.tar.gz", hash = "sha256:a320d6ec644dfd887cc58247a345054fc4d9f981100c45184470068f4b3719b0", size = 159636, upload-time = "2024-04-07T22:30:46.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl", hash = "sha256:b13ff4dd8403369670e2efe684066de9fcb868ad9d7f2b4095d8112142dc9d12", size = 46982, upload-time = "2024-04-07T22:30:44.277Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -633,9 +733,11 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "flake8" }, + { name = "interrogate" }, { name = "mkdocs-material" }, { name = "mkdocstrings", extra = ["python"] }, { name = "pytest" }, + { name = "pytest-cov" }, { name = "sphinx" }, { name = "sphinx-rtd-theme" }, ] @@ -647,11 +749,13 @@ tinydb = [ requires-dist = [ { name = "aiohttp", specifier = ">=3.12.15" }, { name = "flake8", marker = "extra == 'dev'", specifier = ">=7.2.0" }, + { name = "interrogate", marker = "extra == 'dev'", specifier = ">=1.7.0" }, { name = "mkdocs-material", marker = "extra == 'dev'", specifier = ">=9.5.0" }, { name = "mkdocstrings", extras = ["python"], marker = "extra == 'dev'", specifier = ">=0.26.0" }, { name = "paho-mqtt", specifier = ">=2.1.0" }, { name = "pydantic", specifier = ">=2.12.5,<3.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.5" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0.0" }, { name = "requests" }, { name = "shapely", specifier = ">=2.1.2,<3.0.0" }, { name = "sphinx", marker = "extra == 'dev'", specifier = ">=7.4.7" }, @@ -772,6 +876,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, ] +[[package]] +name = "py" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/ff/fec109ceb715d2a6b4c4a85a61af3b40c723a961e8828319fbcb15b868dc/py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", size = 207796, upload-time = "2021-11-04T17:17:01.377Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378", size = 98708, upload-time = "2021-11-04T17:17:00.152Z" }, +] + [[package]] name = "pycodestyle" version = "2.13.0" @@ -913,6 +1026,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1174,6 +1301,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, ] +[[package]] +name = "tabulate" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, +] + [[package]] name = "tinydb" version = "4.8.2" From eb290cb2bf036cad60716366dfaa0ea30aa2636b Mon Sep 17 00:00:00 2001 From: Ian Patterson Date: Fri, 1 May 2026 21:59:42 -0500 Subject: [PATCH 3/3] 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