From 939a1f7a76fe9d288670ca41021eb6c75437d0f8 Mon Sep 17 00:00:00 2001 From: Joshua Benning Date: Fri, 10 Jul 2026 14:03:47 +0200 Subject: [PATCH 1/2] adapter: Allow optional `first`/`second` in relationship elements Metamodel v3.1.2 changed `RelationshipElement.first` and `second` to be optional. This was missed earlier and required by JSON and XML adapters. - `AnnotatedRelationshipElement.__init__` now accepts `first` and `second` as `Optional[Reference]`, matching the parent class. - The JSON and XML deserializers treat both fields as optional default to `None` when absent. - The JSON serializer omits the fields when `None` (XML serializer already handled this). - Added tests for this behaviour. Fixes #525 --- .../aas/adapter/json/json_deserialization.py | 8 ++--- .../aas/adapter/json/json_serialization.py | 5 ++- .../aas/adapter/xml/xml_deserialization.py | 4 +-- sdk/basyx/aas/model/submodel.py | 4 +-- .../adapter/json/test_json_deserialization.py | 25 +++++++++++++ .../adapter/json/test_json_serialization.py | 6 ++++ .../adapter/xml/test_xml_deserialization.py | 36 +++++++++++++++++++ 7 files changed, 79 insertions(+), 9 deletions(-) diff --git a/sdk/basyx/aas/adapter/json/json_deserialization.py b/sdk/basyx/aas/adapter/json/json_deserialization.py index e5e975ea..d3479b2c 100644 --- a/sdk/basyx/aas/adapter/json/json_deserialization.py +++ b/sdk/basyx/aas/adapter/json/json_deserialization.py @@ -635,8 +635,8 @@ def _construct_operation(cls, dct: Dict[str, object], object_class=model.Operati def _construct_relationship_element( cls, dct: Dict[str, object], object_class=model.RelationshipElement) -> model.RelationshipElement: ret = object_class(id_short=None, - first=cls._construct_reference(_get_ts(dct, 'first', dict)), - second=cls._construct_reference(_get_ts(dct, 'second', dict))) + first=cls._construct_reference(_get_ts(dct, 'first', dict)) if 'first' in dct else None, + second=cls._construct_reference(_get_ts(dct, 'second', dict)) if 'second' in dct else None) cls._amend_abstract_attributes(ret, dct) return ret @@ -646,8 +646,8 @@ def _construct_annotated_relationship_element( -> model.AnnotatedRelationshipElement: ret = object_class( id_short=None, - first=cls._construct_reference(_get_ts(dct, 'first', dict)), - second=cls._construct_reference(_get_ts(dct, 'second', dict))) + first=cls._construct_reference(_get_ts(dct, 'first', dict)) if 'first' in dct else None, + second=cls._construct_reference(_get_ts(dct, 'second', dict)) if 'second' in dct else None) cls._amend_abstract_attributes(ret, dct) if not cls.stripped and 'annotations' in dct: for element in _get_ts(dct, 'annotations', list): diff --git a/sdk/basyx/aas/adapter/json/json_serialization.py b/sdk/basyx/aas/adapter/json/json_serialization.py index defef347..d981f228 100644 --- a/sdk/basyx/aas/adapter/json/json_serialization.py +++ b/sdk/basyx/aas/adapter/json/json_serialization.py @@ -567,7 +567,10 @@ def _relationship_element_to_json(cls, obj: model.RelationshipElement) -> Dict[s :return: dict with the serialized attributes of this object """ data = cls._abstract_classes_to_json(obj) - data.update({'first': obj.first, 'second': obj.second}) + if obj.first is not None: + data.update({'first': obj.first}) + if obj.second is not None: + data.update({'second': obj.second}) return data @classmethod diff --git a/sdk/basyx/aas/adapter/xml/xml_deserialization.py b/sdk/basyx/aas/adapter/xml/xml_deserialization.py index c231e142..6bfb67bc 100644 --- a/sdk/basyx/aas/adapter/xml/xml_deserialization.py +++ b/sdk/basyx/aas/adapter/xml/xml_deserialization.py @@ -512,8 +512,8 @@ def _construct_relationship_element_internal(cls, element: etree._Element, objec """ relationship_element = object_class( None, - _child_construct_mandatory(element, NS_AAS + "first", cls.construct_reference), - _child_construct_mandatory(element, NS_AAS + "second", cls.construct_reference) + _failsafe_construct(element.find(NS_AAS + "first"), cls.construct_reference, cls.failsafe), + _failsafe_construct(element.find(NS_AAS + "second"), cls.construct_reference, cls.failsafe) ) cls._amend_abstract_attributes(relationship_element, element) return relationship_element diff --git a/sdk/basyx/aas/model/submodel.py b/sdk/basyx/aas/model/submodel.py index a163c139..a361ce34 100644 --- a/sdk/basyx/aas/model/submodel.py +++ b/sdk/basyx/aas/model/submodel.py @@ -904,8 +904,8 @@ class AnnotatedRelationshipElement(RelationshipElement, base.UniqueIdShortNamesp def __init__(self, id_short: Optional[base.NameType], - first: base.Reference, - second: base.Reference, + first: Optional[base.Reference] = None, + second: Optional[base.Reference] = None, display_name: Optional[base.MultiLanguageNameType] = None, annotation: Iterable[DataElement] = (), category: Optional[base.NameType] = None, diff --git a/sdk/test/adapter/json/test_json_deserialization.py b/sdk/test/adapter/json/test_json_deserialization.py index e8a4a3aa..9067d3e7 100644 --- a/sdk/test/adapter/json/test_json_deserialization.py +++ b/sdk/test/adapter/json/test_json_deserialization.py @@ -329,6 +329,31 @@ def test_stripped_annotated_relationship_element(self) -> None: assert isinstance(are, model.AnnotatedRelationshipElement) self.assertEqual(len(are.annotation), 0) + def test_optional_first_second_relationship_element(self) -> None: + data = """ + { + "modelType": "RelationshipElement", + "idShort": "test_optional_second_relationship_element", + "category": "PARAMETER", + "first": { + "type": "ModelReference", + "keys": [ + { + "type": "Submodel", + "value": "http://example.org/Test_Submodel" + }, + { + "type": "AnnotatedRelationshipElement", + "value": "test_ref" + } + ] + } + }""" + + re = json.loads(data, cls=StrictAASFromJsonDecoder) + self.assertIsInstance(re, model.RelationshipElement) + self.assertIsNone(re.second) + def test_stripped_entity(self) -> None: data = """ { diff --git a/sdk/test/adapter/json/test_json_serialization.py b/sdk/test/adapter/json/test_json_serialization.py index a077594d..136c0411 100644 --- a/sdk/test/adapter/json/test_json_serialization.py +++ b/sdk/test/adapter/json/test_json_serialization.py @@ -208,6 +208,12 @@ def test_stripped_annotated_relationship_element(self) -> None: self._checkNormalAndStripped("annotations", are) + def test_relationship_element_omits_none_first_second(self) -> None: + re = model.RelationshipElement("test_re") + data = json.loads(json.dumps(re, cls=AASToJsonEncoder)) + self.assertNotIn("first", data) + self.assertNotIn("second", data) + def test_stripped_entity(self) -> None: mlp = model.MultiLanguageProperty("test_multi_language_property", category="PARAMETER") entity = model.Entity("test_entity", model.EntityType.CO_MANAGED_ENTITY, statement=[mlp]) diff --git a/sdk/test/adapter/xml/test_xml_deserialization.py b/sdk/test/adapter/xml/test_xml_deserialization.py index 14a5041b..8ca91a25 100644 --- a/sdk/test/adapter/xml/test_xml_deserialization.py +++ b/sdk/test/adapter/xml/test_xml_deserialization.py @@ -472,6 +472,42 @@ def test_data_spec_iec61360_value_without_value_format(self) -> None: self.assertIsNone(ds_content.value_format) + def test_optional_first_second_relationship_element(self) -> None: + xml = _xml_wrap(""" + + + http://example.org/test_submodel + + + test_optional_second_relationship_element + PARAMETER + + ModelReference + + + Submodel + http://example.org/Test_Submodel + + + AnnotatedRelationshipElement + test_ref + + + + + + + + """) + object_store = read_aas_xml_file(io.StringIO(xml), failsafe=False) + submodel = object_store.get_item("http://example.org/test_submodel") + assert isinstance(submodel, model.Submodel) + re = submodel.get_referable("test_optional_second_relationship_element") + self.assertIsInstance(re, model.RelationshipElement) + assert isinstance(re, model.RelationshipElement) + self.assertIsNone(re.second) + + class XmlDeserializationDerivingTest(unittest.TestCase): def test_submodel_constructor_overriding(self) -> None: class EnhancedSubmodel(model.Submodel): From 726257a9add75cd8e1fac8f869f73072f0e05cbd Mon Sep 17 00:00:00 2001 From: Joshua Benning Date: Fri, 10 Jul 2026 14:10:51 +0200 Subject: [PATCH 2/2] test_xml_deserialization: Remove additional empty line --- sdk/test/adapter/xml/test_xml_deserialization.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sdk/test/adapter/xml/test_xml_deserialization.py b/sdk/test/adapter/xml/test_xml_deserialization.py index 8ca91a25..395d23ed 100644 --- a/sdk/test/adapter/xml/test_xml_deserialization.py +++ b/sdk/test/adapter/xml/test_xml_deserialization.py @@ -471,7 +471,6 @@ def test_data_spec_iec61360_value_without_value_format(self) -> None: self.assertEqual("test_value", ds_content.value) self.assertIsNone(ds_content.value_format) - def test_optional_first_second_relationship_element(self) -> None: xml = _xml_wrap("""