Skip to content

Commit c194e2b

Browse files
remove unnecessary deep copy in event builder, update tests to prevent unwarranted failures
1 parent 7e6ac05 commit c194e2b

10 files changed

Lines changed: 287 additions & 13 deletions

src/oshconnect/events/builder.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,11 @@ def with_timestamp(self, timestamp: datetime.datetime) -> EventBuilder:
4141
return self
4242

4343
def build(self) -> Event:
44-
built = self._event.model_copy(deep=True)
44+
# Shallow copy: we want a fresh Event so reset() can't mutate it, but
45+
# `data` and `producer` are references the consumer cares about (often
46+
# not pickleable, e.g. holding a sqlite3.Connection), so we don't clone
47+
# them.
48+
built = self._event.model_copy(deep=False)
4549
self.reset()
4650
return built
4751

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{
2+
"obsFormat": "application/om+json",
3+
"resultSchema": {
4+
"type": "DataRecord",
5+
"name": "weather",
6+
"definition": "urn:osh:data:weather",
7+
"description": "Weather measurements",
8+
"fields": [
9+
{
10+
"type": "Quantity",
11+
"name": "temperature",
12+
"definition": "http://mmisw.org/ont/cf/parameter/air_temperature",
13+
"label": "Air Temperature",
14+
"uom": {
15+
"code": "Cel"
16+
}
17+
},
18+
{
19+
"type": "Quantity",
20+
"name": "pressure",
21+
"definition": "http://mmisw.org/ont/cf/parameter/air_pressure",
22+
"label": "Atmospheric Pressure",
23+
"uom": {
24+
"code": "hPa"
25+
}
26+
},
27+
{
28+
"type": "Quantity",
29+
"name": "windSpeed",
30+
"definition": "http://mmisw.org/ont/cf/parameter/wind_speed",
31+
"label": "Wind Speed",
32+
"uom": {
33+
"code": "m/s"
34+
}
35+
},
36+
{
37+
"type": "Quantity",
38+
"name": "windDirection",
39+
"definition": "http://mmisw.org/ont/cf/parameter/wind_from_direction",
40+
"label": "Wind Direction",
41+
"referenceFrame": "http://www.opengis.net/def/cs/OGC/0/NED",
42+
"axisID": "z",
43+
"uom": {
44+
"code": "deg"
45+
}
46+
}
47+
]
48+
}
49+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"obsFormat": "application/swe+json",
3+
"recordSchema": {
4+
"type": "DataRecord",
5+
"name": "weather",
6+
"definition": "urn:osh:data:weather",
7+
"description": "Weather measurements",
8+
"fields": [
9+
{
10+
"type": "Time",
11+
"name": "time",
12+
"definition": "http://www.opengis.net/def/property/OGC/0/SamplingTime",
13+
"label": "Sampling Time",
14+
"referenceFrame": "http://www.opengis.net/def/trs/BIPM/0/UTC",
15+
"uom": {
16+
"href": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"
17+
}
18+
},
19+
{
20+
"type": "Quantity",
21+
"name": "temperature",
22+
"definition": "http://mmisw.org/ont/cf/parameter/air_temperature",
23+
"label": "Air Temperature",
24+
"uom": {
25+
"code": "Cel"
26+
}
27+
},
28+
{
29+
"type": "Quantity",
30+
"name": "pressure",
31+
"definition": "http://mmisw.org/ont/cf/parameter/air_pressure",
32+
"label": "Atmospheric Pressure",
33+
"uom": {
34+
"code": "hPa"
35+
}
36+
},
37+
{
38+
"type": "Quantity",
39+
"name": "windSpeed",
40+
"definition": "http://mmisw.org/ont/cf/parameter/wind_speed",
41+
"label": "Wind Speed",
42+
"uom": {
43+
"code": "m/s"
44+
}
45+
},
46+
{
47+
"type": "Quantity",
48+
"name": "windDirection",
49+
"definition": "http://mmisw.org/ont/cf/parameter/wind_from_direction",
50+
"label": "Wind Direction",
51+
"referenceFrame": "http://www.opengis.net/def/cs/OGC/0/NED",
52+
"axisID": "z",
53+
"uom": {
54+
"code": "deg"
55+
}
56+
}
57+
]
58+
}
59+
}

tests/test_api_helper.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
from csapi4py.default_api_helpers import APIHelper
1+
from oshconnect.csapi4py import APIHelper
2+
23

34
def test_url_generation():
4-
helper = APIHelper(server_url='localhost', port=8282, protocol='http', username='admin', password='admin', api_root='sensorhub/api')
5+
helper = APIHelper(server_url='localhost', port=8282, protocol='http', username='admin', password='admin')
56
expected_url = "http://localhost:8282/sensorhub/api"
67
url = helper.get_api_root_url()
78
assert url == expected_url

tests/test_api_update.py

Lines changed: 0 additions & 4 deletions
This file was deleted.

tests/test_oshconnect.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
import os
1010
import websockets
1111

12+
from oshconnect import TimePeriod, TimeInstant
1213
from src.oshconnect import OSHConnect, Node
13-
from timemanagement import TimePeriod, TimeInstant
1414

1515
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../src')))
1616

@@ -28,10 +28,10 @@ def test_time_period(self):
2828
assert tps.epoch_time == TimeInstant.from_string("2024-06-18T15:46:32Z").epoch_time
2929
assert tpe.epoch_time == TimeInstant.from_string("2024-06-18T20:00:00Z").epoch_time
3030

31-
tp = TimePeriod(start="now", end="2025-06-18T20:00:00Z")
31+
tp = TimePeriod(start="now", end="2099-06-18T20:00:00Z")
3232
assert tp is not None
3333
assert tp.start == "now"
34-
assert tp.end.epoch_time == TimeInstant.from_string("2025-06-18T20:00:00Z").epoch_time
34+
assert tp.end.epoch_time == TimeInstant.from_string("2099-06-18T20:00:00Z").epoch_time
3535

3636
tp = TimePeriod(start="2024-06-18T20:00:00Z", end="now")
3737
assert tp is not None

tests/test_resource_datamodels.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# Author: Ian Patterson
55
# Contact Email: ian@botts-inc.com
66
# =============================================================================
7-
from src.oshconnect.resource_datamodels import ControlStreamResource
7+
from oshconnect import ControlStreamResource
88

99

1010
def test_control_stream_resource():

tests/test_schema_equivalence.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# =============================================================================
2+
# Copyright (c) 2026 Botts Innovative Research Inc.
3+
# Author: Ian Patterson
4+
# Contact Email: ian@botts-inc.com
5+
# =============================================================================
6+
"""
7+
Verify that OSHConnect's datamodels can faithfully represent the datastream schema
8+
that an OSH server publishes for the FakeWeatherDriver, in both observation
9+
formats served:
10+
11+
- application/om+json (CS API Part 2 §16.1.4 shape: obsFormat + resultSchema)
12+
- application/swe+json (CS API Part 2 §16.2.3 shape: obsFormat + recordSchema
13+
[+ encoding])
14+
15+
Strategy: round-trip the server-supplied schema JSON through the matching
16+
pydantic model (parse -> re-serialize) and assert structural equivalence. If
17+
our datamodels can losslessly express what the Node has, then a schema
18+
*generated* from those same datamodels will match the Node.
19+
20+
Each parametrized case prefers a live node at localhost:8282 (FakeWeatherDriver
21+
running). If the node is unreachable or no weather system is registered, it
22+
falls back to the saved fixture at tests/fixtures/fake_weather_schema_<fmt>.json.
23+
If neither is available, the case is skipped.
24+
"""
25+
from __future__ import annotations
26+
27+
import json
28+
from pathlib import Path
29+
from typing import NamedTuple
30+
31+
import pytest
32+
import requests
33+
34+
from src.oshconnect.schema_datamodels import (
35+
JSONDatastreamRecordSchema,
36+
SWEDatastreamRecordSchema,
37+
)
38+
39+
NODE_URL = "http://localhost:8282/sensorhub/api"
40+
NODE_AUTH = ("admin", "admin")
41+
LIVE_TIMEOUT = 2.0
42+
FIXTURES_DIR = Path(__file__).parent / "fixtures"
43+
44+
45+
class FormatCase(NamedTuple):
46+
obs_format: str
47+
model: type
48+
fixture_path: Path
49+
50+
51+
CASES = [
52+
FormatCase(
53+
obs_format="application/om+json",
54+
model=JSONDatastreamRecordSchema,
55+
fixture_path=FIXTURES_DIR / "fake_weather_schema_omjson.json",
56+
),
57+
FormatCase(
58+
obs_format="application/swe+json",
59+
model=SWEDatastreamRecordSchema,
60+
fixture_path=FIXTURES_DIR / "fake_weather_schema_swejson.json",
61+
),
62+
]
63+
64+
65+
def _find_weather_system(systems: list[dict]) -> dict | None:
66+
"""Pick a system whose name/description/uid mentions 'weather'."""
67+
for sys_ in systems:
68+
props = sys_.get("properties", {}) or {}
69+
haystack = " ".join(
70+
str(x) for x in (
71+
sys_.get("id", ""),
72+
props.get("name", ""),
73+
props.get("description", ""),
74+
props.get("uid", ""),
75+
)
76+
).lower()
77+
if "weather" in haystack:
78+
return sys_
79+
return None
80+
81+
82+
def _try_live_schema(obs_format: str) -> tuple[str, dict] | None:
83+
"""Probe the node at localhost:8282 for a FakeWeather datastream and return
84+
(source_label, schema_json) for the requested obs_format. Returns None on
85+
any failure."""
86+
try:
87+
sys_resp = requests.get(f"{NODE_URL}/systems?f=json", auth=NODE_AUTH, timeout=LIVE_TIMEOUT)
88+
except (requests.ConnectionError, requests.Timeout):
89+
return None
90+
if not sys_resp.ok:
91+
return None
92+
93+
weather = _find_weather_system(sys_resp.json().get("items", []))
94+
if not weather:
95+
return None
96+
97+
sys_id = weather.get("id")
98+
if not sys_id:
99+
return None
100+
101+
ds_resp = requests.get(
102+
f"{NODE_URL}/systems/{sys_id}/datastreams?f=json",
103+
auth=NODE_AUTH, timeout=LIVE_TIMEOUT,
104+
)
105+
if not ds_resp.ok:
106+
return None
107+
datastreams = ds_resp.json().get("items", [])
108+
if not datastreams:
109+
return None
110+
111+
ds_id = datastreams[0].get("id")
112+
schema_resp = requests.get(
113+
f"{NODE_URL}/datastreams/{ds_id}/schema",
114+
params={"obsFormat": obs_format},
115+
auth=NODE_AUTH, timeout=LIVE_TIMEOUT,
116+
)
117+
if not schema_resp.ok:
118+
return None
119+
120+
return (
121+
f"live node 8282 ({obs_format}, system={sys_id}, datastream={ds_id})",
122+
schema_resp.json(),
123+
)
124+
125+
126+
def _try_fixture_schema(path: Path) -> tuple[str, dict] | None:
127+
"""Load the saved fixture if it exists and is non-empty."""
128+
if not path.exists():
129+
return None
130+
text = path.read_text().strip()
131+
if not text or text == "{}":
132+
return None
133+
data = json.loads(text)
134+
if not data:
135+
return None
136+
return f"fixture {path.name}", data
137+
138+
139+
@pytest.mark.parametrize(
140+
"case",
141+
CASES,
142+
ids=lambda c: c.obs_format,
143+
)
144+
def test_fake_weather_schema_round_trips_through_datamodels(case: FormatCase):
145+
source = _try_live_schema(case.obs_format) or _try_fixture_schema(case.fixture_path)
146+
if source is None:
147+
pytest.skip(
148+
f"No live FakeWeather node at {NODE_URL} for {case.obs_format} and no "
149+
f"usable fixture at {case.fixture_path}. To enable: start the "
150+
f"FakeWeatherDriver on the node, or paste a schema JSON into the fixture."
151+
)
152+
label, server_schema = source
153+
154+
parsed = case.model.model_validate(server_schema)
155+
round_tripped = parsed.model_dump(
156+
mode='json', by_alias=True, exclude_none=True, exclude_unset=True,
157+
)
158+
159+
assert server_schema == round_tripped, (
160+
f"Schema round-trip mismatch (source: {label}, model: {case.model.__name__}).\n"
161+
f"server:\n{json.dumps(server_schema, indent=2, sort_keys=True)}\n\n"
162+
f"datamodel re-serialization:\n{json.dumps(round_tripped, indent=2, sort_keys=True)}"
163+
)

tests/test_serialization.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
from oshconnect import Node
12

2-
def test_node_password_serialization(dummy_node):
3+
4+
def test_node_password_serialization():
35
node = Node(protocol='http', address='localhost', port=8080, username='user', password='pass')
46
serialized = node.serialize()
57
assert serialized['password'] == 'pass'

tests/test_streamable_resources.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from src.oshconnect import OSHConnect, Node
1+
from oshconnect import OSHConnect, Node
22

33

44
def test_streamble_observations():

0 commit comments

Comments
 (0)