Skip to content

Commit 9d47ae7

Browse files
relax a few too strict instances of "label" property. Adjust tests to account for this. Add a SchemaFetchWarning to alert users when this fails so it doesn't get missed.
1 parent 602dfaf commit 9d47ae7

6 files changed

Lines changed: 158 additions & 27 deletions

File tree

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.1a7"
3+
version = "0.5.1a9"
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/streamableresource.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,21 @@
7575
from .timemanagement import TimeInstant, TimePeriod, TimeUtils
7676

7777

78+
class SchemaFetchWarning(UserWarning):
79+
"""A datastream/control-stream schema fetch or parse failed during
80+
`Node.discover_systems` / `System.discover_datastreams` /
81+
`System.discover_controlstreams`.
82+
83+
Discovery deliberately does not raise on per-resource schema failures —
84+
one broken schema would otherwise poison the entire listing. The
85+
matching wrapper is still appended (with `record_schema` / `command_schema`
86+
left as ``None``), but the original exception is surfaced both here
87+
(via ``warnings.warn``) and in the root logger at ERROR level (with a
88+
full traceback via ``exc_info=True``). Filter or capture this category
89+
if you want to react programmatically.
90+
"""
91+
92+
7893
@dataclass(kw_only=True)
7994
class Endpoints:
8095
"""Default URL path segments for an OSH server's REST APIs."""
@@ -976,11 +991,12 @@ def discover_datastreams(self) -> list[Datastream]:
976991
SWEDatastreamRecordSchema.from_swejson_dict(schema_resp.json())
977992
)
978993
except Exception as e:
979-
warnings.warn(
994+
msg = (
980995
f"Failed to fetch SWE+JSON schema for datastream "
981-
f"{datastream_objs.ds_id}: {e}",
982-
stacklevel=2,
996+
f"{datastream_objs.ds_id}: {type(e).__name__}: {e}"
983997
)
998+
logging.error(msg, exc_info=True)
999+
warnings.warn(msg, SchemaFetchWarning, stacklevel=2)
9841000
datastreams.append(new_ds)
9851001

9861002
if not [ds.get_underlying_resource() != datastream_objs for ds in self.datastreams]:
@@ -1027,11 +1043,12 @@ def discover_controlstreams(self) -> list[ControlStream]:
10271043
JSONCommandSchema.from_json_dict(schema_resp.json())
10281044
)
10291045
except Exception as e:
1030-
warnings.warn(
1046+
msg = (
10311047
f"Failed to fetch command schema for control stream "
1032-
f"{controlstream_objs.cs_id}: {e}",
1033-
stacklevel=2,
1048+
f"{controlstream_objs.cs_id}: {type(e).__name__}: {e}"
10341049
)
1050+
logging.error(msg, exc_info=True)
1051+
warnings.warn(msg, SchemaFetchWarning, stacklevel=2)
10351052
controlstreams.append(new_cs)
10361053

10371054
if not [cs.get_underlying_resource() != controlstream_objs for cs in self.control_channels]:

src/oshconnect/swe_components.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,6 @@ def _fields_require_name(self):
7878

7979

8080
class VectorSchema(AnyComponentSchema):
81-
label: str = Field(...)
8281
type: Literal["Vector"] = "Vector"
8382
definition: str = Field(...)
8483
reference_frame: str = Field(..., alias='referenceFrame')
@@ -142,7 +141,6 @@ def _items_require_name(self):
142141

143142

144143
class GeometrySchema(AnyComponentSchema):
145-
label: str = Field(...)
146144
type: Literal["Geometry"] = "Geometry"
147145
updatable: bool = Field(False)
148146
optional: bool = Field(False)
@@ -163,7 +161,6 @@ class GeometrySchema(AnyComponentSchema):
163161

164162

165163
class AnySimpleComponentSchema(AnyComponentSchema):
166-
label: str = Field(...)
167164
description: str = Field(None)
168165
type: str = Field(...)
169166
updatable: bool = Field(False)

tests/test_discovery.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from oshconnect import Node, System
2424
from oshconnect.resource_datamodels import DatastreamResource
2525
from oshconnect.schema_datamodels import SWEDatastreamRecordSchema
26+
from oshconnect.streamableresource import SchemaFetchWarning
2627
from oshconnect.timemanagement import TimePeriod
2728

2829
FIXTURES_DIR = Path(__file__).parent / "fixtures"
@@ -191,7 +192,8 @@ def schema_handler(ds_id):
191192
sys = System(name="s", label="S", urn="urn:test:s",
192193
parent_node=node, resource_id="sys-1")
193194

194-
with pytest.warns(UserWarning, match="Failed to fetch SWE\\+JSON schema"):
195+
with pytest.warns(SchemaFetchWarning,
196+
match=r"Failed to fetch SWE\+JSON schema"):
195197
discovered = sys.discover_datastreams()
196198

197199
assert len(discovered) == 2
@@ -200,4 +202,42 @@ def schema_handler(ds_id):
200202
assert isinstance(
201203
by_id["ds-ok"]._underlying_resource.record_schema,
202204
SWEDatastreamRecordSchema,
205+
)
206+
207+
208+
def test_discover_datastreams_logs_traceback_on_schema_failure(node, monkeypatch, caplog):
209+
"""A schema-fetch failure must surface in the root logger with the
210+
full traceback (`exc_info=True`), so users who configure logging
211+
(the common case) actually see *what* broke — not just that
212+
something did."""
213+
swe_schema = json.loads(
214+
(FIXTURES_DIR / "fake_weather_schema_swejson.json").read_text()
215+
)
216+
217+
def schema_handler(ds_id):
218+
if ds_id == "ds-broken":
219+
return _MockResponse({"error": "boom"}, status=500)
220+
return _MockResponse(swe_schema)
221+
222+
_install_dispatching_get(
223+
monkeypatch,
224+
listing_payload=_listing_payload("ds-broken", "ds-ok"),
225+
schema_handler=schema_handler,
226+
)
227+
228+
sys = System(name="s", label="S", urn="urn:test:s",
229+
parent_node=node, resource_id="sys-1")
230+
231+
import logging as _logging
232+
with caplog.at_level(_logging.ERROR):
233+
with pytest.warns(SchemaFetchWarning):
234+
sys.discover_datastreams()
235+
236+
error_records = [r for r in caplog.records if r.levelno == _logging.ERROR]
237+
assert any("ds-broken" in r.getMessage() for r in error_records), (
238+
"expected an ERROR log mentioning the failing datastream id"
239+
)
240+
# exc_info plumbed through: the record carries the original exception
241+
assert any(r.exc_info is not None for r in error_records), (
242+
"expected at least one ERROR record to carry exc_info (traceback)"
203243
)

tests/test_swe_components.py

Lines changed: 92 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -312,49 +312,55 @@ def test_swe_datastream_root_invalid_name_pattern_raises():
312312
# Quantity: [type, definition, label, uom]
313313
# Boolean: [type, definition, label]
314314
# Text: [type, definition, label]
315-
# Vector: [type, definition, referenceFrame, label, coordinates]
315+
# Vector: [type, definition, referenceFrame, coordinates]
316316
# DataRecord:[type, fields]
317-
# Geometry: [type, srs, definition, label]
317+
# Geometry: [type, srs, definition]
318+
#
319+
# `label` is optional everywhere — SWE Common 3 inherits it from
320+
# AbstractDataComponent as optional. OSH emits labelless components
321+
# in the wild (e.g. the SensorLocation Vector); a required `label`
322+
# here would break record-schema parsing during discovery.
318323

319324

320325
def test_quantity_requires_uom():
321326
with pytest.raises(ValidationError, match="uom"):
322327
QuantitySchema(label="X", definition="http://example.org/x")
323328

324329

325-
def test_quantity_requires_label():
326-
with pytest.raises(ValidationError, match="label"):
327-
QuantitySchema(definition="http://example.org/x", uom={"code": "m"})
330+
def test_quantity_label_is_optional():
331+
q = QuantitySchema(definition="http://example.org/x", uom={"code": "m"})
332+
assert q.label is None
328333

329334

330335
def test_quantity_requires_definition():
331336
with pytest.raises(ValidationError, match="definition"):
332337
QuantitySchema(label="X", uom={"code": "m"})
333338

334339

335-
def test_boolean_requires_label_and_definition():
336-
with pytest.raises(ValidationError, match="label"):
337-
BooleanSchema(definition="http://example.org/b")
340+
def test_boolean_label_optional_definition_required():
341+
BooleanSchema(definition="http://example.org/b") # no label — OK
338342
with pytest.raises(ValidationError, match="definition"):
339343
BooleanSchema(label="X")
340344

341345

342-
def test_text_requires_label_and_definition():
343-
with pytest.raises(ValidationError, match="label"):
344-
TextSchema(definition="http://example.org/t")
346+
def test_text_label_optional_definition_required():
347+
TextSchema(definition="http://example.org/t") # no label — OK
345348
with pytest.raises(ValidationError, match="definition"):
346349
TextSchema(label="X")
347350

348351

349-
def test_vector_requires_label_definition_referenceframe_coordinates():
352+
def test_vector_requires_definition_referenceframe_coordinates():
353+
# `label` is intentionally NOT in the required set: SWE Common 3 inherits
354+
# it from AbstractDataComponent as optional, and OSH emits labelless
355+
# Vectors (e.g. SensorLocation). See test_vector_label_is_optional…
350356
base = dict(
351357
label="V", definition="http://example.org/v",
352358
referenceFrame="http://example.org/frames/ENU",
353359
coordinates=[QuantitySchema(name="x", label="X",
354360
definition="http://example.org/x",
355361
uom={"code": "m"})],
356362
)
357-
for missing in ("label", "definition", "referenceFrame", "coordinates"):
363+
for missing in ("definition", "referenceFrame", "coordinates"):
358364
kwargs = {k: v for k, v in base.items() if k != missing}
359365
with pytest.raises(ValidationError):
360366
VectorSchema(**kwargs)
@@ -365,15 +371,23 @@ def test_datarecord_requires_fields():
365371
DataRecordSchema(name="r")
366372

367373

368-
def test_geometry_requires_srs_definition_label():
374+
def test_geometry_requires_srs_and_definition():
375+
# `label` deliberately omitted from required set — SWE Common 3
376+
# inherits it from AbstractDataComponent as optional.
369377
base = dict(label="G", definition="http://example.org/g",
370378
srs="http://www.opengis.net/def/crs/EPSG/0/4326")
371-
for missing in ("label", "definition", "srs"):
379+
for missing in ("definition", "srs"):
372380
kwargs = {k: v for k, v in base.items() if k != missing}
373381
with pytest.raises(ValidationError):
374382
GeometrySchema(**kwargs)
375383

376384

385+
def test_geometry_label_is_optional():
386+
g = GeometrySchema(definition="http://example.org/g",
387+
srs="http://www.opengis.net/def/crs/EPSG/0/4326")
388+
assert g.label is None
389+
390+
377391
# --- B.2 discriminator routing ---------------------------------------------
378392

379393
DISCRIMINATOR_CASES = [
@@ -566,6 +580,69 @@ def test_vector_accepts_quantity_in_coordinates():
566580
})
567581

568582

583+
def test_vector_label_is_optional_per_swe_common3():
584+
# SWE Common 3 Vector inherits AbstractDataComponent.label as optional;
585+
# OSH's SensorLocation datastream emits a labelless Vector. A required
586+
# `label` here would break SWE+JSON schema discovery for any datastream
587+
# carrying a Vector — see the discover_datastreams cascade.
588+
v = VectorSchema.model_validate({
589+
"type": "Vector",
590+
"name": "location",
591+
"definition": "http://www.opengis.net/def/property/OGC/0/SensorLocation",
592+
"referenceFrame": "http://www.opengis.net/def/crs/EPSG/0/4979",
593+
"coordinates": [_quantity_field("x")],
594+
})
595+
assert v.label is None
596+
597+
598+
def test_swe_datastream_schema_parses_osh_sensor_location_shape():
599+
# End-to-end shape mirroring `GET /datastreams/{id}/schema` for OSH's
600+
# built-in `sensorLocation` output (CS API SWE+JSON form).
601+
payload = {
602+
"obsFormat": "application/swe+json",
603+
"recordSchema": {
604+
"type": "DataRecord",
605+
"name": "sensorLocation",
606+
"id": "SENSOR_LOCATION",
607+
"label": "Sensor Location",
608+
"fields": [
609+
{
610+
"type": "Time",
611+
"name": "time",
612+
"definition": "http://www.opengis.net/def/property/OGC/0/SamplingTime",
613+
"label": "Sampling Time",
614+
"referenceFrame": "http://www.opengis.net/def/trs/BIPM/0/UTC",
615+
"uom": {"href": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"},
616+
},
617+
{
618+
"type": "Vector",
619+
"name": "location",
620+
"definition": "http://www.opengis.net/def/property/OGC/0/SensorLocation",
621+
"referenceFrame": "http://www.opengis.net/def/crs/EPSG/0/4979",
622+
"localFrame": "#REF_FRAME_LOCAL",
623+
"coordinates": [
624+
{"type": "Quantity", "name": "lat", "label": "Geodetic Latitude",
625+
"definition": "http://sensorml.com/ont/swe/property/GeodeticLatitude",
626+
"axisID": "Lat", "uom": {"code": "deg"}},
627+
{"type": "Quantity", "name": "lon", "label": "Longitude",
628+
"definition": "http://sensorml.com/ont/swe/property/Longitude",
629+
"axisID": "Lon", "uom": {"code": "deg"}},
630+
{"type": "Quantity", "name": "alt", "label": "Ellipsoidal Height",
631+
"definition": "http://sensorml.com/ont/swe/property/HeightAboveEllipsoid",
632+
"axisID": "h", "uom": {"code": "m"}},
633+
],
634+
},
635+
],
636+
},
637+
}
638+
sw = SWEDatastreamRecordSchema.from_swejson_dict(payload)
639+
vec = sw.record_schema.fields[1]
640+
assert vec.type == "Vector"
641+
assert vec.label is None
642+
assert vec.reference_frame == "http://www.opengis.net/def/crs/EPSG/0/4979"
643+
assert [c.name for c in vec.coordinates] == ["lat", "lon", "alt"]
644+
645+
569646
# --- B.6 DataRecord.fields minItems: 1 -------------------------------------
570647

571648
def test_datarecord_empty_fields_rejected():

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)