Skip to content

Commit 602dfaf

Browse files
fix an issue residing in the default setup for system resources where the properties were not honored due to only accepting the default value for the type property
1 parent 4a02781 commit 602dfaf

8 files changed

Lines changed: 225 additions & 36 deletions

File tree

docs/source/architecture/construction.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ with open('my_app_config.json') as f:
237237
node = Node.from_storage_dict(cfg['nodes'][0])
238238
for sys_dict in cfg['systems']:
239239
sys = System.from_storage_dict(sys_dict, node)
240-
node.add_new_system(sys)
240+
node.add_system(sys)
241241
```
242242

243243
## What about new datastreams/controlstreams without going through System?

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "oshconnect"
3-
version = "0.5.1a5"
3+
version = "0.5.1a7"
44
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."
55
readme = "README.md"
66
authors = [

src/oshconnect/oshconnectapi.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -303,9 +303,7 @@ def add_system_to_node(self, system: System, target_node: Node, insert_resource:
303303
:return:
304304
"""
305305
if target_node in self._nodes:
306-
target_node.add_new_system(system)
307-
if insert_resource:
308-
system.insert_self()
306+
target_node.add_system(system, insert_resource=insert_resource)
309307
self._systems.append(system)
310308
return
311309

src/oshconnect/resource_datamodels.py

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,19 @@ class SystemResource(BaseModel):
139139
def to_smljson_dict(self) -> dict:
140140
"""Render this system as an `application/sml+json` dict (SensorML JSON encoding).
141141
142-
Sets ``feature_type = "PhysicalSystem"`` to match the SML discriminator
143-
before dumping. Output keys are camelCase per the CS API wire format.
142+
The ``type`` discriminator (``PhysicalSystem``,
143+
``PhysicalComponent``, ``SimpleProcess``, ``AggregateProcess``,
144+
etc.) is preserved from ``self.feature_type`` when set —
145+
important for cross-node sync, where the source's SML kind
146+
determines how OSH surfaces ``featureType`` (e.g. ``Sensor``
147+
vs. ``System``). Defaults to ``"PhysicalSystem"`` only when
148+
``feature_type`` is unset, so callers building a bare
149+
``SystemResource`` still get a valid SML body. Does not
150+
mutate ``self``.
144151
"""
145-
self.feature_type = "PhysicalSystem"
146-
return self.model_dump(by_alias=True, exclude_none=True, mode='json')
152+
dumped = self.model_dump(by_alias=True, exclude_none=True, mode='json')
153+
dumped.setdefault("type", "PhysicalSystem")
154+
return dumped
147155

148156
def to_smljson(self) -> str:
149157
"""JSON-string variant of `to_smljson_dict`."""
@@ -152,12 +160,14 @@ def to_smljson(self) -> str:
152160
def to_geojson_dict(self) -> dict:
153161
"""Render this system as an `application/geo+json` dict.
154162
155-
Sets ``feature_type = "Feature"`` to match the GeoJSON discriminator
156-
before dumping. Useful when posting to endpoints that expect the
157-
GeoJSON Feature shape.
163+
The ``type`` field is always set to ``"Feature"`` per the
164+
GeoJSON spec, regardless of ``self.feature_type`` — that's the
165+
whole point of this rendering variant. Does not mutate
166+
``self``.
158167
"""
159-
self.feature_type = "Feature"
160-
return self.model_dump(by_alias=True, exclude_none=True, mode='json')
168+
dumped = self.model_dump(by_alias=True, exclude_none=True, mode='json')
169+
dumped["type"] = "Feature"
170+
return dumped
161171

162172
def to_geojson(self) -> str:
163173
"""JSON-string variant of `to_geojson_dict`."""

src/oshconnect/streamableresource.py

Lines changed: 56 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -311,31 +311,32 @@ def discover_systems(self) -> list[System] | None:
311311
else:
312312
return None
313313

314-
def add_new_system(self, system: System):
315-
"""Attach a system to this node without inserting it server-side.
316-
317-
Use `add_system(system, insert_resource=True)` if you also want to
318-
POST it to the server.
319-
"""
320-
system.set_parent_node(self)
321-
self._systems.append(system)
322-
323314
def get_api_helper(self) -> APIHelper:
324315
"""Return the `APIHelper` this node uses for HTTP calls."""
325316
return self._api_helper
326317

327318
# System Management
328319

329-
def add_system(self, system: System, insert_resource: bool = False):
330-
"""
331-
Add a system to the target node.
332-
:param system: System object
333-
:param insert_resource: Whether to insert the system into the target node's server, default is False
334-
:return:
320+
def add_system(self, system: System, insert_resource: bool = False) -> System:
321+
"""Attach a system to this node.
322+
323+
When ``insert_resource=True``, the system is first POSTed to the
324+
server via ``system.insert_self()`` (which populates its
325+
server-assigned resource id), then attached locally — so the
326+
system enters this node's collection already carrying its real
327+
id. With ``insert_resource=False`` the system is attached
328+
in-memory only; useful when reconstructing state from a
329+
datastore or staging a system before a deferred POST.
330+
331+
:param system: ``System`` object to attach.
332+
:param insert_resource: Whether to POST the system to the
333+
server before attaching it locally.
334+
:return: The same ``System`` (now parented to this node and
335+
tracked in ``self.systems()``).
335336
"""
336337
if insert_resource:
337338
system.insert_self()
338-
self.add_new_system(system)
339+
system.set_parent_node(self)
339340
self._systems.append(system)
340341
return system
341342

@@ -1103,10 +1104,35 @@ def from_system_resource(system_resource: SystemResource, parent_node: Node) ->
11031104

11041105
def to_system_resource(self) -> SystemResource:
11051106
"""Render this `System` as a `SystemResource` pydantic model
1106-
suitable for POSTing to the server. Wrapper-specific: assembles
1107-
attached datastreams into the resource's ``outputs`` list.
1107+
suitable for POSTing to the server.
1108+
1109+
When this wrapper already carries an ``_underlying_resource``
1110+
(e.g. populated by ``from_csapi_dict``, ``set_system_resource``,
1111+
or a prior ``retrieve_resource`` call), all of its fields are
1112+
preserved into a deep copy — so cross-node sync, partial
1113+
updates, and re-POSTs round-trip everything the source carried,
1114+
not just ``uniqueId`` / ``label`` / a hardcoded
1115+
``PhysicalSystem`` type. Currently-attached datastreams are
1116+
always reflected into ``outputs`` so newly-added children come
1117+
along.
1118+
1119+
When no underlying resource is present (i.e. during this
1120+
wrapper's own ``__init__``), a thin shell is built from
1121+
wrapper attrs and the SML type defaults to ``PhysicalSystem``.
11081122
"""
1109-
resource = SystemResource(uid=self.urn, label=self.name, feature_type='PhysicalSystem')
1123+
underlying = getattr(self, '_underlying_resource', None)
1124+
if underlying is not None:
1125+
resource = underlying.model_copy(deep=True)
1126+
# Pick up any wrapper-side updates the user made directly
1127+
# on the System (the wrapper doesn't proxy these into the
1128+
# resource on assignment).
1129+
if self.urn and not resource.uid:
1130+
resource.uid = self.urn
1131+
if self.name and not resource.label:
1132+
resource.label = self.name
1133+
else:
1134+
resource = SystemResource(uid=self.urn, label=self.name,
1135+
feature_type='PhysicalSystem')
11101136
if self.datastreams:
11111137
resource.outputs = [ds.get_underlying_resource() for ds in self.datastreams]
11121138
return resource
@@ -1300,16 +1326,26 @@ def insert_self(self):
13001326
"""POST this system to the server (Content-Type
13011327
``application/sml+json``) and capture the new resource ID from
13021328
the ``Location`` response header.
1329+
1330+
Server-assigned fields (``id``, ``links``) are stripped from
1331+
the body before POST so a re-POSTed (e.g. cross-node-synced)
1332+
system doesn't leak the source server's identifier or links to
1333+
the destination — the destination assigns its own.
13031334
"""
1335+
body_resource = self.to_system_resource().model_copy(deep=True)
1336+
body_resource.system_id = None
1337+
body_resource.links = None
13041338
res = self._parent_node.get_api_helper().create_resource(
13051339
APIResourceTypes.SYSTEM,
1306-
self.to_system_resource().model_dump_json(by_alias=True, exclude_none=True),
1340+
body_resource.model_dump_json(by_alias=True, exclude_none=True),
13071341
req_headers={'Content-Type': 'application/sml+json'})
13081342

13091343
if res.ok:
13101344
location = res.headers['Location']
13111345
sys_id = location.split('/')[-1]
13121346
self._resource_id = sys_id
1347+
if self._underlying_resource is not None:
1348+
self._underlying_resource.system_id = sys_id
13131349
print(f'Created system: {self._resource_id}')
13141350

13151351
def retrieve_resource(self):

tests/test_csapi_serialization.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,151 @@ def test_system_full_chain_geojson_dict_to_resource_to_wrapper(node):
149149
assert sys.name == "GeoSys2"
150150

151151

152+
# ---------------------------------------------------------------------------
153+
# SML type preservation and non-mutation
154+
# ---------------------------------------------------------------------------
155+
156+
def test_to_smljson_preserves_non_default_feature_type():
157+
"""A source whose SML type is ``PhysicalComponent`` (which OSH
158+
surfaces as ``featureType: Sensor``) must round-trip through
159+
``to_smljson_dict`` without being collapsed back to
160+
``PhysicalSystem``. Regression guard for cross-node sync."""
161+
src = SystemResource(uid="urn:test:s1", label="S1",
162+
feature_type="PhysicalComponent")
163+
dumped = src.to_smljson_dict()
164+
assert dumped["type"] == "PhysicalComponent"
165+
166+
167+
def test_to_smljson_defaults_to_physical_system_when_unset():
168+
"""When ``feature_type`` is unset, the SML body still gets a
169+
sensible default so callers building a bare SystemResource
170+
continue to produce a valid SML body."""
171+
src = SystemResource(uid="urn:test:s1", label="S1")
172+
dumped = src.to_smljson_dict()
173+
assert dumped["type"] == "PhysicalSystem"
174+
175+
176+
def test_to_smljson_does_not_mutate_feature_type():
177+
"""Pre-fix, ``to_smljson_dict`` set ``self.feature_type`` as a
178+
side effect, which clobbered the source's SML kind. After the
179+
fix, the model is untouched."""
180+
src = SystemResource(uid="urn:test:s1", label="S1",
181+
feature_type="PhysicalComponent")
182+
src.to_smljson_dict()
183+
assert src.feature_type == "PhysicalComponent"
184+
185+
186+
def test_to_geojson_always_emits_feature_without_mutating():
187+
"""GeoJSON form requires ``type: Feature`` per spec, regardless
188+
of ``feature_type`` on the model. The model itself stays
189+
unmutated."""
190+
src = SystemResource(uid="urn:test:s1", label="S1",
191+
feature_type="PhysicalComponent")
192+
dumped = src.to_geojson_dict()
193+
assert dumped["type"] == "Feature"
194+
assert src.feature_type == "PhysicalComponent"
195+
196+
197+
# ---------------------------------------------------------------------------
198+
# System.to_system_resource preserves _underlying_resource
199+
# ---------------------------------------------------------------------------
200+
201+
def test_to_system_resource_preserves_full_underlying(node):
202+
"""When the wrapper carries a full ``_underlying_resource`` (e.g.,
203+
populated by discovery / ``from_csapi_dict``), the resource
204+
rendered for POST keeps every field — not just uid/label/type."""
205+
raw = {
206+
"type": "PhysicalComponent",
207+
"id": "src-server-id-abc",
208+
"uniqueId": "urn:test:source:1",
209+
"label": "Source Sensor",
210+
"description": "Original description",
211+
"definition": "http://www.opengis.net/def/system",
212+
"keywords": ["thermal", "imaging"],
213+
}
214+
res = SystemResource.from_smljson_dict(raw)
215+
sys = System.from_resource(res, node)
216+
217+
rendered = sys.to_system_resource()
218+
219+
# Type preserved (was hardcoded to PhysicalSystem pre-fix).
220+
assert rendered.feature_type == "PhysicalComponent"
221+
# Other fields preserved (were silently dropped pre-fix).
222+
assert rendered.description == "Original description"
223+
assert rendered.definition == "http://www.opengis.net/def/system"
224+
assert rendered.keywords == ["thermal", "imaging"]
225+
226+
227+
def test_to_system_resource_thin_shell_for_freshly_constructed(node):
228+
"""A System constructed from scratch (no parsed resource) still
229+
produces a sensible thin shell with default ``PhysicalSystem``
230+
type — backward-compat with code that doesn't go through
231+
discovery."""
232+
sys = System(name="Fresh", label="Fresh", urn="urn:test:fresh:1",
233+
parent_node=node)
234+
rendered = sys.to_system_resource()
235+
assert rendered.feature_type == "PhysicalSystem"
236+
assert rendered.uid == "urn:test:fresh:1"
237+
238+
239+
# ---------------------------------------------------------------------------
240+
# insert_self strips server-assigned fields from the POST body
241+
# ---------------------------------------------------------------------------
242+
243+
class _MockResponse:
244+
status_code = 201
245+
ok = True
246+
text = ""
247+
headers = {"Location": "http://localhost:8282/sensorhub/api/systems/dest-id-xyz"}
248+
249+
250+
def _capture_post(into: dict):
251+
def _f(url, params=None, headers=None, auth=None, data=None, json=None, **kwargs):
252+
into["url"] = str(url)
253+
into["data"] = data
254+
into["json"] = json
255+
return _MockResponse()
256+
return _f
257+
258+
259+
def test_insert_self_strips_id_and_links_from_body(node, monkeypatch):
260+
"""When re-POSTing a discovered system to a destination node, the
261+
source's server-assigned ``id`` and ``links`` must not leak into
262+
the body — the destination assigns its own. Regression guard for
263+
cross-node sync."""
264+
raw = {
265+
"type": "PhysicalComponent",
266+
"id": "source-side-id",
267+
"uniqueId": "urn:test:source:1",
268+
"label": "Source Sensor",
269+
"links": [{"href": "http://source.example/extra", "rel": "alternate"}],
270+
}
271+
res = SystemResource.from_smljson_dict(raw)
272+
sys = System.from_resource(res, node)
273+
274+
captured: dict = {}
275+
monkeypatch.setattr(
276+
"oshconnect.csapi4py.request_wrappers.requests.post",
277+
_capture_post(captured),
278+
)
279+
280+
sys.insert_self()
281+
282+
body = json.loads(captured["data"])
283+
# Source-assigned identifiers must NOT be present in the POST body.
284+
assert "id" not in body, (
285+
"POST body must not carry source's server-assigned id"
286+
)
287+
assert "links" not in body, (
288+
"POST body must not carry source's server-assigned links"
289+
)
290+
# But the SML kind from the source IS preserved.
291+
assert body["type"] == "PhysicalComponent"
292+
assert body["uniqueId"] == "urn:test:source:1"
293+
# Wrapper picked up the destination's id from the Location header.
294+
assert sys._resource_id == "dest-id-xyz"
295+
296+
152297
# ===========================================================================
153298
# Datastream: resource representation, schema document, observations
154299
# ===========================================================================

tests/test_datastore.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ def test_save_all_and_load_all(self):
255255
sm = SessionManager()
256256
node = make_node(sm)
257257
system = make_system(node)
258-
node.add_new_system(system)
258+
node.add_system(system)
259259

260260
store.save_all([node])
261261
nodes = store.load_all(session_manager=sm)

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)