Skip to content

Commit 97cd5e2

Browse files
Enforced the SWE Common 3 SoftNamedProperty rule: components carry an optional name, but every binding context (DataRecord.fields, DataChoice.items, Vector.coordinates, DataArray/Matrix.elementType, and the root recordSchema/resultSchema/parametersSchema of datastream and control‑stream wrappers)
now validates that each bound child has a non‑empty name matching the NameToken pattern ^[A-Za-z][A-Za-z0-9_\-]*$.
1 parent 02ca719 commit 97cd5e2

4 files changed

Lines changed: 94 additions & 14 deletions

File tree

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,6 @@ tinydb = ["tinydb>=4.8.0,<5.0.0"]
2828

2929
[tool.setuptools]
3030
packages = {find = { where = ["src/"]}}
31+
32+
[tool.pytest.ini_options]
33+
pythonpath = ["src"]

src/oshconnect/schema_datamodels.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@
99
from datetime import datetime
1010
from typing import Union, List
1111

12-
from pydantic import BaseModel, Field, SerializeAsAny, field_validator, HttpUrl, ConfigDict
12+
from pydantic import BaseModel, Field, SerializeAsAny, field_validator, model_validator, HttpUrl, ConfigDict
1313

1414
from .api_utils import Link, URI
1515
from .csapi4py.constants import ObservationFormat
1616
from .encoding import Encoding
1717
from .geometry import Geometry
18-
from .swe_components import AnyComponent
18+
from .swe_components import AnyComponent, check_named
1919

2020
"""
2121
In many of the top level resource models there is a "schema" field of some description. These models are meant to ease
@@ -53,6 +53,11 @@ class SWEJSONCommandSchema(CommandSchema):
5353
encoding: SerializeAsAny[Encoding] = Field(...)
5454
record_schema: AnyComponent = Field(..., alias='recordSchema')
5555

56+
@model_validator(mode="after")
57+
def _root_record_schema_requires_name(self):
58+
check_named(self.record_schema, "SWEJSONCommandSchema.recordSchema")
59+
return self
60+
5661

5762
class JSONCommandSchema(CommandSchema):
5863
"""
@@ -65,6 +70,15 @@ class JSONCommandSchema(CommandSchema):
6570
result_schema: AnyComponent = Field(None, alias='resultSchema')
6671
feasibility_schema: AnyComponent = Field(None, alias='feasibilityResultSchema')
6772

73+
@model_validator(mode="after")
74+
def _root_schemas_require_name(self):
75+
check_named(self.params_schema, "JSONCommandSchema.parametersSchema")
76+
if self.result_schema is not None:
77+
check_named(self.result_schema, "JSONCommandSchema.resultSchema")
78+
if self.feasibility_schema is not None:
79+
check_named(self.feasibility_schema, "JSONCommandSchema.feasibilityResultSchema")
80+
return self
81+
6882

6983
class DatastreamRecordSchema(BaseModel):
7084
"""
@@ -92,6 +106,11 @@ def check_check_obs_format(cls, v):
92106
raise ValueError('obsFormat must be on of the SWE formats')
93107
return v
94108

109+
@model_validator(mode="after")
110+
def _root_record_schema_requires_name(self):
111+
check_named(self.record_schema, "SWEDatastreamRecordSchema.recordSchema")
112+
return self
113+
95114

96115
class JSONDatastreamRecordSchema(DatastreamRecordSchema):
97116
"""Datastream observation schema for the JSON media types
@@ -117,6 +136,14 @@ def _check_obs_format(cls, v):
117136
)
118137
return v
119138

139+
@model_validator(mode="after")
140+
def _root_schemas_require_name(self):
141+
if self.result_schema is not None:
142+
check_named(self.result_schema, "JSONDatastreamRecordSchema.resultSchema")
143+
if self.parameters_schema is not None:
144+
check_named(self.parameters_schema, "JSONDatastreamRecordSchema.parametersSchema")
145+
return self
146+
120147

121148
class ObservationOMJSONInline(BaseModel):
122149
"""

src/oshconnect/streamableresource.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -789,7 +789,9 @@ def get_system_resource(self) -> SystemResource:
789789
def add_insert_datastream(self, datarecord_schema: DataRecordSchema):
790790
"""
791791
Adds a datastream to the system while also inserting it into the system's parent node via HTTP POST.
792-
:param datarecord_schema: DataRecordSchema to be used to define the datastream
792+
:param datarecord_schema: DataRecordSchema to be used to define the datastream. Must carry a `name`
793+
matching NameToken (^[A-Za-z][A-Za-z0-9_\\-]*$); SWE Common 3 wraps DataStream.elementType in
794+
SoftNamedProperty, so the root component requires a name.
793795
:return:
794796
"""
795797
print(f'Adding datastream: {datarecord_schema.model_dump_json(exclude_none=True, by_alias=True)}')
@@ -831,7 +833,9 @@ def add_and_insert_control_stream(self, control_stream_record_schema: DataRecord
831833
"""
832834
Accepts a DataRecordSchema and creates a JSON encoded schema structure ControlStreamResource, which is inserted
833835
into the parent system via the host node.
834-
:param control_stream_record_schema: DataRecordSchema to be used for the control stream
836+
:param control_stream_record_schema: DataRecordSchema to be used for the control stream. Must carry a `name`
837+
matching NameToken (^[A-Za-z][A-Za-z0-9_\\-]*$); JSONCommandSchema.parametersSchema is wrapped in
838+
SoftNamedProperty so the root component requires a name.
835839
:param input_name: Name of the input, if None the label of the schema is converted to lower and stripped of whitespace
836840
:return: ControlStream object added to the system
837841
"""

src/oshconnect/swe_components.py

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,34 @@
77

88
from __future__ import annotations
99

10+
import re
1011
from numbers import Real
1112
from typing import Union, Any, Literal, Annotated
1213

13-
from pydantic import BaseModel, ConfigDict, Field, field_validator, SerializeAsAny
14+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator, SerializeAsAny
1415

1516
from .csapi4py.constants import GeometryTypes
1617
from .api_utils import UCUMCode, URI
1718
from .geometry import Geometry
1819

20+
# SWE Common 3 NameToken: basicTypes.json#/$defs/NameToken
21+
_NAME_TOKEN_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_\-]*$")
22+
23+
24+
def check_named(component, location: str) -> None:
25+
"""Validate that a component bound via SoftNamedProperty carries a NameToken `name`."""
26+
name = getattr(component, "name", None)
27+
if not name:
28+
raise ValueError(
29+
f"{location}: a component bound here must carry `name` (SWE Common 3 SoftNamedProperty)."
30+
)
31+
if not _NAME_TOKEN_RE.match(name):
32+
raise ValueError(
33+
f"{location}: `name` {name!r} does not match NameToken pattern "
34+
f"^[A-Za-z][A-Za-z0-9_\\-]*$."
35+
)
36+
37+
1938
"""
2039
NOTE: The following classes are used to represent the Record Schemas that are required for use with Datastreams
2140
The names are likely to change to include a "Schema" suffix to differentiate them from the actual data structures.
@@ -34,6 +53,11 @@ class AnyComponentSchema(BaseModel):
3453
model_config = ConfigDict(populate_by_name=True)
3554
type: str = Field(...)
3655
id: str = Field(None)
56+
# Wire-format flat carrier for SoftNamedProperty.name. Optional on the component
57+
# itself (per AbstractDataComponent.json); enforced as required by parent
58+
# binding-context validators (DataRecord/DataChoice/Vector/DataArray/Matrix and
59+
# the datastream/controlstream schema wrappers in schema_datamodels.py).
60+
name: str = Field(None)
3761
label: str = Field(None)
3862
description: str = Field(None)
3963
updatable: bool = Field(False)
@@ -43,44 +67,61 @@ class AnyComponentSchema(BaseModel):
4367

4468
class DataRecordSchema(AnyComponentSchema):
4569
type: Literal["DataRecord"] = "DataRecord"
46-
# `name` is not part of AbstractDataComponent in SWE Common 3 — it belongs to
47-
# the SoftNamedProperty wrapper that binds a component as a record field. We
48-
# accept it here only because the OSH server emits `name` at the root-level
49-
# DataRecord of a datastream's recordSchema/resultSchema. See
50-
# docs/osh_spec_deviations.md (root-component-name).
51-
name: str = Field(None)
5270
fields: list["AnyComponent"] = Field(...)
5371

72+
@model_validator(mode="after")
73+
def _fields_require_name(self):
74+
for i, f in enumerate(self.fields):
75+
check_named(f, f"DataRecord.fields[{i}]")
76+
return self
77+
5478

5579
class VectorSchema(AnyComponentSchema):
5680
label: str = Field(...)
57-
name: str = Field(...)
5881
type: Literal["Vector"] = "Vector"
5982
definition: str = Field(...)
6083
reference_frame: str = Field(..., alias='referenceFrame')
6184
local_frame: str = Field(None, alias='localFrame')
6285
# TODO: VERIFY might need to be moved further down when these are defined
6386
coordinates: SerializeAsAny[Union[list[CountSchema], list[QuantitySchema], list[TimeSchema]]] = Field(...)
6487

88+
@model_validator(mode="after")
89+
def _coordinates_require_name(self):
90+
for i, c in enumerate(self.coordinates):
91+
check_named(c, f"Vector.coordinates[{i}]")
92+
return self
93+
6594

6695
class DataArraySchema(AnyComponentSchema):
6796
type: Literal["DataArray"] = "DataArray"
68-
name: str = Field(...)
6997
element_count: dict | str | CountSchema = Field(..., alias='elementCount') # Should type of Count
7098
element_type: "AnyComponent" = Field(..., alias='elementType')
7199
encoding: str = Field(...) # TODO: implement an encodings class
72100
values: list = Field(None)
73101

102+
@model_validator(mode="after")
103+
def _element_type_requires_name(self):
104+
check_named(self.element_type, "DataArray.elementType")
105+
return self
106+
74107

75108
class MatrixSchema(AnyComponentSchema):
76109
type: Literal["Matrix"] = "Matrix"
77110
element_count: dict | str | CountSchema = Field(..., alias='elementCount') # Should be type of Count
111+
# TODO: spec defines Matrix.elementType as a single component (allOf SoftNamedProperty + AnyComponent),
112+
# not a list. Cardinality fix is out of scope for the name-validator change.
78113
element_type: list["AnyComponent"] = Field(..., alias='elementType')
79114
encoding: str = Field(...) # TODO: implement an encodings class
80115
values: list = Field(None)
81116
reference_frame: str = Field(None)
82117
local_frame: str = Field(None)
83118

119+
@model_validator(mode="after")
120+
def _element_type_requires_name(self):
121+
for i, et in enumerate(self.element_type):
122+
check_named(et, f"Matrix.elementType[{i}]")
123+
return self
124+
84125

85126
class DataChoiceSchema(AnyComponentSchema):
86127
type: Literal["DataChoice"] = "DataChoice"
@@ -89,6 +130,12 @@ class DataChoiceSchema(AnyComponentSchema):
89130
choice_value: CategorySchema = Field(..., alias='choiceValue') # TODO: Might be called "choiceValues"
90131
items: list["AnyComponent"] = Field(...)
91132

133+
@model_validator(mode="after")
134+
def _items_require_name(self):
135+
for i, item in enumerate(self.items):
136+
check_named(item, f"DataChoice.items[{i}]")
137+
return self
138+
92139

93140
class GeometrySchema(AnyComponentSchema):
94141
label: str = Field(...)
@@ -127,7 +174,6 @@ class AnySimpleComponentSchema(AnyComponentSchema):
127174
nil_values: list = Field(None, alias='nilValues')
128175
constraint: Any = Field(None)
129176
value: Any = Field(None)
130-
name: str = Field(...)
131177

132178

133179
class AnyScalarComponentSchema(AnySimpleComponentSchema):

0 commit comments

Comments
 (0)