From e15d4a53621f8eabeb013a8065d41a0e3b8fbe92 Mon Sep 17 00:00:00 2001 From: Sankalp Thakur Date: Sat, 5 Sep 2026 19:26:31 +0400 Subject: [PATCH 1/5] model.base: Copy Qualifier and Extension data in update_nss_from `NamespaceSet.update_nss_from()` is how backends refresh a namespace from a newer copy of the same objects. Matching Referable objects get their attributes copied in place. Matching Qualifier and Extension objects were only looked up; their value and the rest of the payload stayed on the old object. A PUT of a Submodel with the same qualifier type therefore kept the previous value. Backends already treat a Qualifier of the same type and an Extension of the same name as the same object, so those fields have to be copied onto the object that already lives in the target set. Fixes #602 --- sdk/basyx/aas/model/base.py | 42 ++++++++++++-- sdk/test/model/test_base.py | 108 ++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 5 deletions(-) diff --git a/sdk/basyx/aas/model/base.py b/sdk/basyx/aas/model/base.py index 84650fe17..630b171c0 100644 --- a/sdk/basyx/aas/model/base.py +++ b/sdk/basyx/aas/model/base.py @@ -1777,6 +1777,22 @@ def name(self, name: NameType) -> None: # Redundant to the line above. However, this way, we make sure that we really update the _name self._name = name + def update_from(self, other: "Extension"): + """ + Internal function to update the object's attributes from a different version of the exact same object. + + This function should not be used directly. It is typically used by backend implementations (database adapters, + protocol clients, etc.) to update the object's data, after ``update_nss_from()`` has been called. + + :param other: The object to update from + """ + # Assign value_type first so the incoming value is cast against the new type. + self.value_type = other.value_type + self.value = other.value + self.refers_to = other.refers_to + self.semantic_id = other.semantic_id + self.supplemental_semantic_id = other.supplemental_semantic_id + class HasKind(metaclass=abc.ABCMeta): """ @@ -1933,6 +1949,23 @@ def type(self, type_: QualifierType) -> None: # Redundant to the line above. However, this way, we make sure that we really update the _type self._type = type_ + def update_from(self, other: "Qualifier"): + """ + Internal function to update the object's attributes from a different version of the exact same object. + + This function should not be used directly. It is typically used by backend implementations (database adapters, + protocol clients, etc.) to update the object's data, after ``update_nss_from()`` has been called. + + :param other: The object to update from + """ + # Assign value_type first so the incoming value is cast against the new type. + self.value_type = other.value_type + self.value = other.value + self.value_id = other.value_id + self.kind = other.kind + self.semantic_id = other.semantic_id + self.supplemental_semantic_id = other.supplemental_semantic_id + @_string_constraints.constrain_value_type_iec61360("value") class ValueReferencePair: @@ -2367,7 +2400,6 @@ def get( attribute_value if case_sensitive else attribute_value.upper(), default ) - # Todo: Implement function including tests def update_nss_from(self, other: "NamespaceSet"): """ Update a NamespaceSet from a given NamespaceSet. @@ -2390,20 +2422,20 @@ def update_nss_from(self, other: "NamespaceSet"): referable.update_from(other_object) # type: ignore elif isinstance(other_object, Qualifier): backend, case_sensitive = self._backend["type"] - qualifier = backend[ # noqa: F841 qualifier currently unused + qualifier = backend[ other_object.type if case_sensitive else other_object.type.upper() ] - # qualifier.update_from(other_object) # TODO: What should happend here? Remove noqa when done + qualifier.update_from(other_object) # type: ignore elif isinstance(other_object, Extension): backend, case_sensitive = self._backend["name"] - extension = backend[ # noqa: F841 extension currently unused + extension = backend[ other_object.name if case_sensitive else other_object.name.upper() ] - # extension.update_from(other_object) # TODO: What should happend here? Remove noqa when done + extension.update_from(other_object) # type: ignore else: raise TypeError("Type not implemented") except KeyError: diff --git a/sdk/test/model/test_base.py b/sdk/test/model/test_base.py index 2e2e75ca0..000280bd8 100644 --- a/sdk/test/model/test_base.py +++ b/sdk/test/model/test_base.py @@ -312,6 +312,26 @@ def test_update_from(self): ) self.assertIs(example_relel.parent, example_submodel) + def test_update_from_qualifier_and_extension(self): + submodel = model.Submodel("https://example.org/Test_Submodel") + qualifier = model.Qualifier("q", model.datatypes.String, "old") + extension = model.Extension("e", model.datatypes.String, "old") + submodel.add_qualifier(qualifier) + submodel.add_extension(extension) + + other = model.Submodel("https://example.org/Test_Submodel") + other.add_qualifier(model.Qualifier("q", model.datatypes.String, "new")) + other.add_extension(model.Extension("e", model.datatypes.String, "new")) + + submodel.update_from(other) + + self.assertIs(submodel.get_qualifier_by_type("q"), qualifier) + self.assertEqual("new", qualifier.value) + self.assertIs(qualifier.parent, submodel) + self.assertIs(submodel.get_extension_by_name("e"), extension) + self.assertEqual("new", extension.value) + self.assertIs(extension.parent, submodel) + def test_update_commit_qualifier_extension_semantic_id(self): submodel = model.Submodel("https://example.org/Test_Submodel") qualifier = model.Qualifier("test", model.datatypes.String) @@ -372,6 +392,12 @@ def __init__(self, values=()): self.set1 = model.NamespaceSet(self, [("type", False)], values) +class ExampleNamespaceExtension(model.HasExtension): + def __init__(self, values=()): + super().__init__() + self.set1 = model.NamespaceSet(self, [("name", True)], values) + + class ModelNamespaceTest(unittest.TestCase): _namespace_class = ExampleNamespaceReferable _namespace_class_qualifier = ExampleNamespaceQualifier @@ -843,6 +869,88 @@ def test_Namespaceset_update_from(self) -> None: namespace1.get_referable("Prop2") self.assertIsNone(prop2.parent) + def test_Namespaceset_update_from_qualifier_and_extension(self) -> None: + # ExampleNamespaceReferable also indexes semantic_id, so Qualifier/Extension + # objects belong in namespaces that only key on type/name. + qualifier_ns1 = self._namespace_class_qualifier() + qualifier1 = model.Qualifier( + "type1", + model.datatypes.Int, + 1, + kind=model.QualifierKind.CONCEPT_QUALIFIER, + ) + qualifier2 = model.Qualifier("type2", model.datatypes.Int, 2) + qualifier_ns1.set1.add(qualifier1) + qualifier_ns1.set1.add(qualifier2) + + qualifier_ns2 = self._namespace_class_qualifier() + qualifier_ns2.set1.add( + model.Qualifier( + "type1", + model.datatypes.String, + "updated", + kind=model.QualifierKind.VALUE_QUALIFIER, + semantic_id=self.propSemanticID, + ) + ) + qualifier_ns2.set1.add(model.Qualifier("type3", model.datatypes.Int, 3)) + qualifier_ns1.set1.update_nss_from(qualifier_ns2.set1) + + self.assertIs( + qualifier_ns1.set1.get_object_by_attribute("type", "type1"), qualifier1 + ) + self.assertEqual("updated", qualifier1.value) + self.assertIs(qualifier1.value_type, model.datatypes.String) + self.assertEqual(model.QualifierKind.VALUE_QUALIFIER, qualifier1.kind) + self.assertEqual(self.propSemanticID, qualifier1.semantic_id) + self.assertIs(qualifier1.parent, qualifier_ns1) + qualifier3 = qualifier_ns1.set1.get_object_by_attribute("type", "type3") + self.assertIs(qualifier3.parent, qualifier_ns1) + assert isinstance(qualifier3, model.Qualifier) + self.assertEqual(3, qualifier3.value) + self.assertFalse(qualifier_ns1.set1.contains_id("type", "type2")) + self.assertIsNone(qualifier2.parent) + + extension_ns1 = ExampleNamespaceExtension() + extension1 = model.Extension("Ext1", model.datatypes.Int, 1) + extension2 = model.Extension("Ext2", model.datatypes.Int, 2) + extension_ns1.set1.add(extension1) + extension_ns1.set1.add(extension2) + + refers_to = { + model.ModelReference( + (model.Key(model.KeyTypes.SUBMODEL, "urn:x-test:submodel"),), + model.Submodel, + ) + } + extension_ns2 = ExampleNamespaceExtension() + extension_ns2.set1.add( + model.Extension( + "Ext1", + model.datatypes.String, + "updated", + refers_to=refers_to, + semantic_id=self.propSemanticID, + ) + ) + extension_ns2.set1.add(model.Extension("Ext3", model.datatypes.Int, 3)) + extension_ns1.set1.update_nss_from(extension_ns2.set1) + + self.assertIs( + extension_ns1.set1.get_object_by_attribute("name", "Ext1"), extension1 + ) + self.assertEqual("updated", extension1.value) + self.assertIs(extension1.value_type, model.datatypes.String) + self.assertEqual(refers_to, extension1.refers_to) + self.assertEqual(self.propSemanticID, extension1.semantic_id) + self.assertIs(extension1.parent, extension_ns1) + extension3 = extension_ns1.set1.get_object_by_attribute("name", "Ext3") + self.assertIs(extension3.parent, extension_ns1) + assert isinstance(extension3, model.Extension) + self.assertEqual(3, extension3.value) + self.assertFalse(extension_ns1.set1.contains_id("name", "Ext2")) + self.assertIsNone(extension2.parent) + def test_qualifiable_id_short_namespace(self) -> None: prop1 = model.Property("Prop1", model.datatypes.Int, 1) qualifier1 = model.Qualifier("Qualifier1", model.datatypes.Int, 2) From 0b16e07f68753f631a687701495d5b8f64501b2a Mon Sep 17 00:00:00 2001 From: Sankalp Thakur Date: Fri, 9 Oct 2026 00:43:28 +0400 Subject: [PATCH 2/5] model.base: Copy name and type in Qualifier/Extension update_from `Extension.update_from()` and `Qualifier.update_from()` left `name` and `type` as they were, so they did not produce a full copy of the other version of the object. The setters of both attributes reject a value that is already present in the parent namespace, which includes the object's own current value. As `update_nss_from()` matches the objects by exactly that attribute, it is only assigned if it actually differs. `Extension.update_from()` also took over the `refers_to` set of the other object, so that both extensions shared the same set instance afterwards. --- sdk/basyx/aas/model/base.py | 12 ++++++-- sdk/test/model/test_base.py | 55 +++++++++++++++++-------------------- 2 files changed, 34 insertions(+), 33 deletions(-) diff --git a/sdk/basyx/aas/model/base.py b/sdk/basyx/aas/model/base.py index 630b171c0..c08479f32 100644 --- a/sdk/basyx/aas/model/base.py +++ b/sdk/basyx/aas/model/base.py @@ -1777,7 +1777,7 @@ def name(self, name: NameType) -> None: # Redundant to the line above. However, this way, we make sure that we really update the _name self._name = name - def update_from(self, other: "Extension"): + def update_from(self, other: "Extension") -> None: """ Internal function to update the object's attributes from a different version of the exact same object. @@ -1786,10 +1786,13 @@ def update_from(self, other: "Extension"): :param other: The object to update from """ + # The name setter rejects the current name as a duplicate, so only assign a changed name + if self.name != other.name: + self.name = other.name # Assign value_type first so the incoming value is cast against the new type. self.value_type = other.value_type self.value = other.value - self.refers_to = other.refers_to + self.refers_to = set(other.refers_to) self.semantic_id = other.semantic_id self.supplemental_semantic_id = other.supplemental_semantic_id @@ -1949,7 +1952,7 @@ def type(self, type_: QualifierType) -> None: # Redundant to the line above. However, this way, we make sure that we really update the _type self._type = type_ - def update_from(self, other: "Qualifier"): + def update_from(self, other: "Qualifier") -> None: """ Internal function to update the object's attributes from a different version of the exact same object. @@ -1958,6 +1961,9 @@ def update_from(self, other: "Qualifier"): :param other: The object to update from """ + # The type setter rejects the current type as a duplicate, so only assign a changed type + if self.type != other.type: + self.type = other.type # Assign value_type first so the incoming value is cast against the new type. self.value_type = other.value_type self.value = other.value diff --git a/sdk/test/model/test_base.py b/sdk/test/model/test_base.py index 000280bd8..664a0bef0 100644 --- a/sdk/test/model/test_base.py +++ b/sdk/test/model/test_base.py @@ -284,9 +284,17 @@ def test_get_id_short_path(self): def test_update_from(self): example_submodel = example_aas.create_example_submodel() example_relel = example_submodel.get_referable("ExampleRelationshipElement") + example_qualifier = model.Qualifier("q", model.datatypes.String, "old") + example_extension = model.Extension("e", model.datatypes.String, "old") + example_submodel.add_qualifier(example_qualifier) + example_submodel.add_extension(example_extension) other_submodel = example_aas.create_example_submodel() other_relel = other_submodel.get_referable("ExampleRelationshipElement") + other_qualifier = model.Qualifier("q", model.datatypes.String, "new") + other_extension = model.Extension("e", model.datatypes.String, "new") + other_submodel.add_qualifier(other_qualifier) + other_submodel.add_extension(other_extension) other_submodel.category = "NewCat" other_relel.category = "NewRelElCat" @@ -295,6 +303,8 @@ def test_update_from(self): example_submodel.update_from(other_submodel) self.assertEqual("NewCat", example_submodel.category) self.assertEqual("NewRelElCat", example_relel.category) + self.assertEqual("new", example_qualifier.value) + self.assertEqual("new", example_extension.value) # References to Referable objects shall remain stable self.assertIs( example_relel, example_submodel.get_referable("ExampleRelationshipElement") @@ -305,32 +315,17 @@ def test_update_from(self): "id_short", "ExampleRelationshipElement" ), ) + # The same holds for Qualifier and Extension objects + self.assertIs(example_qualifier, example_submodel.get_qualifier_by_type("q")) + self.assertIs(example_extension, example_submodel.get_extension_by_name("e")) # Check Namespace & parent consistency self.assertIs( example_submodel.namespace_element_sets[0], example_submodel.submodel_element, ) self.assertIs(example_relel.parent, example_submodel) - - def test_update_from_qualifier_and_extension(self): - submodel = model.Submodel("https://example.org/Test_Submodel") - qualifier = model.Qualifier("q", model.datatypes.String, "old") - extension = model.Extension("e", model.datatypes.String, "old") - submodel.add_qualifier(qualifier) - submodel.add_extension(extension) - - other = model.Submodel("https://example.org/Test_Submodel") - other.add_qualifier(model.Qualifier("q", model.datatypes.String, "new")) - other.add_extension(model.Extension("e", model.datatypes.String, "new")) - - submodel.update_from(other) - - self.assertIs(submodel.get_qualifier_by_type("q"), qualifier) - self.assertEqual("new", qualifier.value) - self.assertIs(qualifier.parent, submodel) - self.assertIs(submodel.get_extension_by_name("e"), extension) - self.assertEqual("new", extension.value) - self.assertIs(extension.parent, submodel) + self.assertIs(example_qualifier.parent, example_submodel) + self.assertIs(example_extension.parent, example_submodel) def test_update_commit_qualifier_extension_semantic_id(self): submodel = model.Submodel("https://example.org/Test_Submodel") @@ -869,7 +864,7 @@ def test_Namespaceset_update_from(self) -> None: namespace1.get_referable("Prop2") self.assertIsNone(prop2.parent) - def test_Namespaceset_update_from_qualifier_and_extension(self) -> None: + # Qualifiers and Extensions are updated the same way, matched by type and name. # ExampleNamespaceReferable also indexes semantic_id, so Qualifier/Extension # objects belong in namespaces that only key on type/name. qualifier_ns1 = self._namespace_class_qualifier() @@ -924,15 +919,14 @@ def test_Namespaceset_update_from_qualifier_and_extension(self) -> None: ) } extension_ns2 = ExampleNamespaceExtension() - extension_ns2.set1.add( - model.Extension( - "Ext1", - model.datatypes.String, - "updated", - refers_to=refers_to, - semantic_id=self.propSemanticID, - ) - ) + extension1_new = model.Extension( + "Ext1", + model.datatypes.String, + "updated", + refers_to=refers_to, + semantic_id=self.propSemanticID, + ) + extension_ns2.set1.add(extension1_new) extension_ns2.set1.add(model.Extension("Ext3", model.datatypes.Int, 3)) extension_ns1.set1.update_nss_from(extension_ns2.set1) @@ -942,6 +936,7 @@ def test_Namespaceset_update_from_qualifier_and_extension(self) -> None: self.assertEqual("updated", extension1.value) self.assertIs(extension1.value_type, model.datatypes.String) self.assertEqual(refers_to, extension1.refers_to) + self.assertIsNot(extension1_new.refers_to, extension1.refers_to) self.assertEqual(self.propSemanticID, extension1.semantic_id) self.assertIs(extension1.parent, extension_ns1) extension3 = extension_ns1.set1.get_object_by_attribute("name", "Ext3") From b0eb4d8c582347b4fdfa538f68fb36340d3cebb6 Mon Sep 17 00:00:00 2001 From: Sankalp Thakur Date: Thu, 22 Oct 2026 19:03:08 +0400 Subject: [PATCH 3/5] model.base: Only guard the lookup in update_nss_from `NamespaceSet.update_nss_from()` called `update_from()` of the matching object inside the `try` block that reads a `KeyError` as "the object is not in this set". A `KeyError` raised by `update_from()` itself was therefore taken for a missing object: the other object was removed from its set and added a second time, the caller got an unrelated AASd-021 (or -022, -077) violation, and the object in the other set was gone. An error raised while updating a matching object has to reach the caller and leave both sets as they were, so only the lookup is guarded. The Todo above `update_nss_from()` is back in a narrower form. The removal of objects that are missing in the other set still has known defects, which are independent of #602. --- sdk/basyx/aas/model/base.py | 17 +++++--- sdk/test/model/test_base.py | 80 +++++++++++++++++++++++++++++++++++-- 2 files changed, 88 insertions(+), 9 deletions(-) diff --git a/sdk/basyx/aas/model/base.py b/sdk/basyx/aas/model/base.py index c08479f32..98fbedf2b 100644 --- a/sdk/basyx/aas/model/base.py +++ b/sdk/basyx/aas/model/base.py @@ -2406,6 +2406,10 @@ def get( attribute_value if case_sensitive else attribute_value.upper(), default ) + # Todo: The removal of objects that are missing in `other` below has known defects. An object is queued once per + # attribute that both sets index, and the second removal raises a KeyError. The lookup in `other` ignores the + # case sensitivity of `other`, attribute names are compared with `is` and an object that is falsy counts as + # missing. Also, objects that are neither Referable, Qualifier nor Extension raise a TypeError. def update_nss_from(self, other: "NamespaceSet"): """ Update a NamespaceSet from a given NamespaceSet. @@ -2420,33 +2424,34 @@ def update_nss_from(self, other: "NamespaceSet"): try: if isinstance(other_object, Referable): backend, case_sensitive = self._backend["id_short"] - referable = backend[ + existing_object = backend[ other_object.id_short if case_sensitive else other_object.id_short.upper() ] - referable.update_from(other_object) # type: ignore elif isinstance(other_object, Qualifier): backend, case_sensitive = self._backend["type"] - qualifier = backend[ + existing_object = backend[ other_object.type if case_sensitive else other_object.type.upper() ] - qualifier.update_from(other_object) # type: ignore elif isinstance(other_object, Extension): backend, case_sensitive = self._backend["name"] - extension = backend[ + existing_object = backend[ other_object.name if case_sensitive else other_object.name.upper() ] - extension.update_from(other_object) # type: ignore else: raise TypeError("Type not implemented") except KeyError: # other object is not in NamespaceSet objects_to_add.append(other_object) + continue + # Only the lookup above tells that an object is missing. A KeyError raised by update_from() must not be + # mistaken for that, as the object would be removed from `other` and added to this set a second time. + existing_object.update_from(other_object) # type: ignore for attr_name, (backend, case_sensitive) in self._backend.items(): for attr_name_other, ( backend_other, diff --git a/sdk/test/model/test_base.py b/sdk/test/model/test_base.py index 664a0bef0..e2e5d98fc 100644 --- a/sdk/test/model/test_base.py +++ b/sdk/test/model/test_base.py @@ -6,7 +6,7 @@ # SPDX-License-Identifier: MIT import unittest -from typing import Callable, Dict, Iterable, List, Optional, Type, TypeVar +from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Type, TypeVar from basyx.aas import model from basyx.aas.examples.data import example_aas @@ -393,9 +393,25 @@ def __init__(self, values=()): self.set1 = model.NamespaceSet(self, [("name", True)], values) +class FailingUpdateProperty(model.Property): + def update_from(self, other): + raise KeyError("update_from failed") + + +class FailingUpdateQualifier(model.Qualifier): + def update_from(self, other): + raise KeyError("update_from failed") + + +class FailingUpdateExtension(model.Extension): + def update_from(self, other): + raise KeyError("update_from failed") + + class ModelNamespaceTest(unittest.TestCase): _namespace_class = ExampleNamespaceReferable _namespace_class_qualifier = ExampleNamespaceQualifier + _namespace_class_extension = ExampleNamespaceExtension def setUp(self): self.propSemanticID = model.ExternalReference( @@ -906,7 +922,7 @@ def test_Namespaceset_update_from(self) -> None: self.assertFalse(qualifier_ns1.set1.contains_id("type", "type2")) self.assertIsNone(qualifier2.parent) - extension_ns1 = ExampleNamespaceExtension() + extension_ns1 = self._namespace_class_extension() extension1 = model.Extension("Ext1", model.datatypes.Int, 1) extension2 = model.Extension("Ext2", model.datatypes.Int, 2) extension_ns1.set1.add(extension1) @@ -918,7 +934,7 @@ def test_Namespaceset_update_from(self) -> None: model.Submodel, ) } - extension_ns2 = ExampleNamespaceExtension() + extension_ns2 = self._namespace_class_extension() extension1_new = model.Extension( "Ext1", model.datatypes.String, @@ -946,6 +962,50 @@ def test_Namespaceset_update_from(self) -> None: self.assertFalse(extension_ns1.set1.contains_id("name", "Ext2")) self.assertIsNone(extension2.parent) + def test_Namespaceset_update_from_key_error(self) -> None: + # A KeyError raised by update_from() has to reach the caller. It does not tell that the object is missing in + # the NamespaceSet, so nothing may be moved from the other set into this one. + test_cases: List[Tuple[Any, str, Any, Any]] = [ + ( + self._namespace_class, + "set2", + FailingUpdateProperty( + "Prop1", model.datatypes.Int, 1, semantic_id=self.propSemanticID + ), + model.Property( + "Prop1", model.datatypes.Int, 2, semantic_id=self.propSemanticID + ), + ), + ( + self._namespace_class_qualifier, + "set1", + FailingUpdateQualifier("type1", model.datatypes.Int, 1), + model.Qualifier("type1", model.datatypes.Int, 2), + ), + ( + self._namespace_class_extension, + "set1", + FailingUpdateExtension("Ext1", model.datatypes.Int, 1), + model.Extension("Ext1", model.datatypes.Int, 2), + ), + ] + for namespace_class, set_name, old_object, new_object in test_cases: + with self.subTest(type(new_object).__name__): + namespace1 = namespace_class() + namespace2 = namespace_class() + set1 = getattr(namespace1, set_name) + set2 = getattr(namespace2, set_name) + set1.add(old_object) + set2.add(new_object) + + with self.assertRaises(KeyError) as cm: + set1.update_nss_from(set2) + self.assertEqual("'update_from failed'", str(cm.exception)) + self.assertEqual([old_object], list(set1)) + self.assertIs(old_object.parent, namespace1) + self.assertEqual([new_object], list(set2)) + self.assertIs(new_object.parent, namespace2) + def test_qualifiable_id_short_namespace(self) -> None: prop1 = model.Property("Prop1", model.datatypes.Int, 1) qualifier1 = model.Qualifier("Qualifier1", model.datatypes.Int, 2) @@ -996,8 +1056,22 @@ def __init__(self, values=()): self.set4 = model.NamespaceSet(self, [("type", True)]) +class ExampleOrderedNamespaceQualifier(model.Qualifiable): + def __init__(self, values=()): + super().__init__() + self.set1 = model.OrderedNamespaceSet(self, [("type", False)], values) + + +class ExampleOrderedNamespaceExtension(model.HasExtension): + def __init__(self, values=()): + super().__init__() + self.set1 = model.OrderedNamespaceSet(self, [("name", True)], values) + + class ModelOrderedNamespaceTest(ModelNamespaceTest): _namespace_class = ExampleOrderedNamespace # type: ignore + _namespace_class_qualifier = ExampleOrderedNamespaceQualifier # type: ignore + _namespace_class_extension = ExampleOrderedNamespaceExtension # type: ignore def test_OrderedNamespace(self) -> None: # Tests from ModelNamespaceTest are inherited, but with ExampleOrderedNamespace instead of ExampleNamespace From b0a0cf778bf0c7e5503b60859f6cbd93d5b542ef Mon Sep 17 00:00:00 2001 From: Sankalp Thakur Date: Thu, 22 Oct 2026 19:05:08 +0400 Subject: [PATCH 4/5] model.base: Respect AASd-118 in Qualifier/Extension update_from `Qualifier.update_from()` and `Extension.update_from()` assigned the `semantic_id` before the `supplemental_semantic_id`. An update from an object without a `semantic_id` therefore ran into AASd-118 (the semantic ID must not be removed while there are supplemental ones) and stopped half way through, after other attributes had already been copied. The old supplemental semantic IDs have to go before the semantic ID is replaced, and the new ones are set last. Both classes share that order in `HasSemantics._update_semantic_ids_from()`, so it cannot drift apart. It is private on purpose: `Referable` precedes `HasSemantics` in the MRO of every class that is both, so a public `update_from()` on `HasSemantics` would never run for them, and `update_nss_from()` has no branch for plain `HasSemantics` objects. The docstrings said that `update_from()` is used after `update_nss_from()` has been called. It is called by `update_nss_from()` for the objects that both sets have. --- sdk/basyx/aas/model/base.py | 25 ++++-- sdk/test/model/test_base.py | 167 +++++++++++++++++++++++++++++++++++- 2 files changed, 181 insertions(+), 11 deletions(-) diff --git a/sdk/basyx/aas/model/base.py b/sdk/basyx/aas/model/base.py index 98fbedf2b..4764ec736 100644 --- a/sdk/basyx/aas/model/base.py +++ b/sdk/basyx/aas/model/base.py @@ -1700,6 +1700,17 @@ def supplemental_semantic_id(self) -> ConstrainedList[Reference]: def supplemental_semantic_id(self, supplemental_semantic_id: Iterable[Reference]): self._supplemental_semantic_id[:] = supplemental_semantic_id + def _update_semantic_ids_from(self, other: "HasSemantics") -> None: + """ + Copy the ``semantic_id`` and the ``supplemental_semantic_id`` of another object. + + The old supplemental semantic IDs are cleared before the semantic ID is replaced, as the semantic ID must not + be removed while there are supplemental semantic IDs (Constraint AASd-118). The new ones are set last. + """ + self.supplemental_semantic_id.clear() + self.semantic_id = other.semantic_id + self.supplemental_semantic_id = other.supplemental_semantic_id + class Extension(HasSemantics): """ @@ -1781,20 +1792,19 @@ def update_from(self, other: "Extension") -> None: """ Internal function to update the object's attributes from a different version of the exact same object. - This function should not be used directly. It is typically used by backend implementations (database adapters, - protocol clients, etc.) to update the object's data, after ``update_nss_from()`` has been called. + This function should not be used directly. ``NamespaceSet.update_nss_from()`` calls it for every object that is + present in both NamespaceSets. :param other: The object to update from """ # The name setter rejects the current name as a duplicate, so only assign a changed name if self.name != other.name: self.name = other.name + self._update_semantic_ids_from(other) # Assign value_type first so the incoming value is cast against the new type. self.value_type = other.value_type self.value = other.value self.refers_to = set(other.refers_to) - self.semantic_id = other.semantic_id - self.supplemental_semantic_id = other.supplemental_semantic_id class HasKind(metaclass=abc.ABCMeta): @@ -1956,21 +1966,20 @@ def update_from(self, other: "Qualifier") -> None: """ Internal function to update the object's attributes from a different version of the exact same object. - This function should not be used directly. It is typically used by backend implementations (database adapters, - protocol clients, etc.) to update the object's data, after ``update_nss_from()`` has been called. + This function should not be used directly. ``NamespaceSet.update_nss_from()`` calls it for every object that is + present in both NamespaceSets. :param other: The object to update from """ # The type setter rejects the current type as a duplicate, so only assign a changed type if self.type != other.type: self.type = other.type + self._update_semantic_ids_from(other) # Assign value_type first so the incoming value is cast against the new type. self.value_type = other.value_type self.value = other.value self.value_id = other.value_id self.kind = other.kind - self.semantic_id = other.semantic_id - self.supplemental_semantic_id = other.supplemental_semantic_id @_string_constraints.constrain_value_type_iec61360("value") diff --git a/sdk/test/model/test_base.py b/sdk/test/model/test_base.py index e2e5d98fc..b15b2c5fb 100644 --- a/sdk/test/model/test_base.py +++ b/sdk/test/model/test_base.py @@ -120,6 +120,12 @@ def generate_example_referable_with_namespace( return example_referable +def _global_reference(value: str) -> model.ExternalReference: + return model.ExternalReference( + (model.Key(model.KeyTypes.GLOBAL_REFERENCE, value),) + ) + + class ReferableTest(unittest.TestCase): def test_id_short_constraint_aasd_002(self): test_object = ExampleReferable() @@ -284,14 +290,31 @@ def test_get_id_short_path(self): def test_update_from(self): example_submodel = example_aas.create_example_submodel() example_relel = example_submodel.get_referable("ExampleRelationshipElement") - example_qualifier = model.Qualifier("q", model.datatypes.String, "old") - example_extension = model.Extension("e", model.datatypes.String, "old") + semantic_id = _global_reference("urn:x-test:semantic") + supplemental_semantic_id = _global_reference("urn:x-test:supplemental") + value_id = _global_reference("urn:x-test:value") + example_qualifier = model.Qualifier( + "q", + model.datatypes.String, + "old", + semantic_id=semantic_id, + supplemental_semantic_id=[supplemental_semantic_id], + ) + example_extension = model.Extension( + "e", + model.datatypes.String, + "old", + semantic_id=semantic_id, + supplemental_semantic_id=[supplemental_semantic_id], + ) example_submodel.add_qualifier(example_qualifier) example_submodel.add_extension(example_extension) other_submodel = example_aas.create_example_submodel() other_relel = other_submodel.get_referable("ExampleRelationshipElement") - other_qualifier = model.Qualifier("q", model.datatypes.String, "new") + other_qualifier = model.Qualifier( + "q", model.datatypes.String, "new", value_id=value_id + ) other_extension = model.Extension("e", model.datatypes.String, "new") other_submodel.add_qualifier(other_qualifier) other_submodel.add_extension(other_extension) @@ -304,7 +327,13 @@ def test_update_from(self): self.assertEqual("NewCat", example_submodel.category) self.assertEqual("NewRelElCat", example_relel.category) self.assertEqual("new", example_qualifier.value) + self.assertEqual(value_id, example_qualifier.value_id) self.assertEqual("new", example_extension.value) + # The other versions have no semantic_id, which takes the supplemental_semantic_id with it + self.assertIsNone(example_qualifier.semantic_id) + self.assertEqual(0, len(example_qualifier.supplemental_semantic_id)) + self.assertIsNone(example_extension.semantic_id) + self.assertEqual(0, len(example_extension.supplemental_semantic_id)) # References to Referable objects shall remain stable self.assertIs( example_relel, example_submodel.get_referable("ExampleRelationshipElement") @@ -900,8 +929,10 @@ def test_Namespaceset_update_from(self) -> None: "type1", model.datatypes.String, "updated", + value_id=self.propSemanticID2, kind=model.QualifierKind.VALUE_QUALIFIER, semantic_id=self.propSemanticID, + supplemental_semantic_id=[self.propSemanticID3], ) ) qualifier_ns2.set1.add(model.Qualifier("type3", model.datatypes.Int, 3)) @@ -913,7 +944,11 @@ def test_Namespaceset_update_from(self) -> None: self.assertEqual("updated", qualifier1.value) self.assertIs(qualifier1.value_type, model.datatypes.String) self.assertEqual(model.QualifierKind.VALUE_QUALIFIER, qualifier1.kind) + self.assertEqual(self.propSemanticID2, qualifier1.value_id) self.assertEqual(self.propSemanticID, qualifier1.semantic_id) + self.assertEqual( + [self.propSemanticID3], list(qualifier1.supplemental_semantic_id) + ) self.assertIs(qualifier1.parent, qualifier_ns1) qualifier3 = qualifier_ns1.set1.get_object_by_attribute("type", "type3") self.assertIs(qualifier3.parent, qualifier_ns1) @@ -941,6 +976,7 @@ def test_Namespaceset_update_from(self) -> None: "updated", refers_to=refers_to, semantic_id=self.propSemanticID, + supplemental_semantic_id=[self.propSemanticID2], ) extension_ns2.set1.add(extension1_new) extension_ns2.set1.add(model.Extension("Ext3", model.datatypes.Int, 3)) @@ -954,6 +990,9 @@ def test_Namespaceset_update_from(self) -> None: self.assertEqual(refers_to, extension1.refers_to) self.assertIsNot(extension1_new.refers_to, extension1.refers_to) self.assertEqual(self.propSemanticID, extension1.semantic_id) + self.assertEqual( + [self.propSemanticID2], list(extension1.supplemental_semantic_id) + ) self.assertIs(extension1.parent, extension_ns1) extension3 = extension_ns1.set1.get_object_by_attribute("name", "Ext3") self.assertIs(extension3.parent, extension_ns1) @@ -1629,6 +1668,63 @@ def test_set_value(self): qualifier.value = None self.assertIsNone(qualifier.value) + def test_update_from(self) -> None: + submodel = model.Submodel("urn:x-test:submodel") + qualifier = model.Qualifier( + "q", + model.datatypes.String, + "old", + value_id=_global_reference("urn:x-test:old-value-id"), + kind=model.QualifierKind.CONCEPT_QUALIFIER, + semantic_id=_global_reference("urn:x-test:old-semantic-id"), + supplemental_semantic_id=[_global_reference("urn:x-test:old-supplemental")], + ) + submodel.add_qualifier(qualifier) + other = model.Qualifier( + "q", + model.datatypes.Int, + 2, + value_id=_global_reference("urn:x-test:value-id"), + kind=model.QualifierKind.VALUE_QUALIFIER, + semantic_id=_global_reference("urn:x-test:semantic-id"), + supplemental_semantic_id=[ + _global_reference("urn:x-test:supplemental-1"), + _global_reference("urn:x-test:supplemental-2"), + ], + ) + + qualifier.update_from(other) + self.assertIs(qualifier.value_type, model.datatypes.Int) + self.assertEqual(2, qualifier.value) + self.assertEqual(other.value_id, qualifier.value_id) + self.assertEqual(model.QualifierKind.VALUE_QUALIFIER, qualifier.kind) + self.assertEqual(other.semantic_id, qualifier.semantic_id) + self.assertEqual( + list(other.supplemental_semantic_id), + list(qualifier.supplemental_semantic_id), + ) + self.assertIsNot( + other.supplemental_semantic_id, qualifier.supplemental_semantic_id + ) + self.assertIs(submodel.get_qualifier_by_type("q"), qualifier) + + def test_update_from_without_semantic_id(self) -> None: + # Constraint AASd-118: The semantic_id must not be removed while there is a supplemental_semantic_id + submodel = model.Submodel("urn:x-test:submodel") + qualifier = model.Qualifier( + "q", + model.datatypes.String, + "old", + semantic_id=_global_reference("urn:x-test:semantic-id"), + supplemental_semantic_id=[_global_reference("urn:x-test:supplemental")], + ) + submodel.add_qualifier(qualifier) + + qualifier.update_from(model.Qualifier("q", model.datatypes.String, "new")) + self.assertEqual("new", qualifier.value) + self.assertIsNone(qualifier.semantic_id) + self.assertEqual(0, len(qualifier.supplemental_semantic_id)) + class ExtensionTest(unittest.TestCase): def test_set_value(self): @@ -1643,6 +1739,71 @@ def test_set_value(self): "ValueType must be set, if value is not None", str(cm.exception) ) + def test_update_from(self) -> None: + submodel = model.Submodel("urn:x-test:submodel") + extension = model.Extension( + "e", + model.datatypes.String, + "old", + refers_to=[ + model.ModelReference( + (model.Key(model.KeyTypes.SUBMODEL, "urn:x-test:old"),), + model.Submodel, + ) + ], + semantic_id=_global_reference("urn:x-test:old-semantic-id"), + supplemental_semantic_id=[_global_reference("urn:x-test:old-supplemental")], + ) + submodel.add_extension(extension) + other = model.Extension( + "e", + model.datatypes.Int, + 2, + refers_to=[ + model.ModelReference( + (model.Key(model.KeyTypes.SUBMODEL, "urn:x-test:new"),), + model.Submodel, + ) + ], + semantic_id=_global_reference("urn:x-test:semantic-id"), + supplemental_semantic_id=[ + _global_reference("urn:x-test:supplemental-1"), + _global_reference("urn:x-test:supplemental-2"), + ], + ) + + extension.update_from(other) + self.assertIs(extension.value_type, model.datatypes.Int) + self.assertEqual(2, extension.value) + self.assertEqual(other.refers_to, extension.refers_to) + self.assertIsNot(other.refers_to, extension.refers_to) + self.assertEqual(other.semantic_id, extension.semantic_id) + self.assertEqual( + list(other.supplemental_semantic_id), + list(extension.supplemental_semantic_id), + ) + self.assertIsNot( + other.supplemental_semantic_id, extension.supplemental_semantic_id + ) + self.assertIs(submodel.get_extension_by_name("e"), extension) + + def test_update_from_without_semantic_id(self) -> None: + # Constraint AASd-118: The semantic_id must not be removed while there is a supplemental_semantic_id + submodel = model.Submodel("urn:x-test:submodel") + extension = model.Extension( + "e", + model.datatypes.String, + "old", + semantic_id=_global_reference("urn:x-test:semantic-id"), + supplemental_semantic_id=[_global_reference("urn:x-test:supplemental")], + ) + submodel.add_extension(extension) + + extension.update_from(model.Extension("e", model.datatypes.String, "new")) + self.assertEqual("new", extension.value) + self.assertIsNone(extension.semantic_id) + self.assertEqual(0, len(extension.supplemental_semantic_id)) + class ValueReferencePairTest(unittest.TestCase): def test_set_value(self): From 2a37c9f8b1c1373bc9e276a16b89613713d5ee21 Mon Sep 17 00:00:00 2001 From: Sankalp Thakur Date: Thu, 22 Oct 2026 19:08:31 +0400 Subject: [PATCH 5/5] model.base: Do not count a Qualifier or Extension as its own duplicate The `Qualifier.type` and `Extension.name` setters raise a `KeyError` if the value is already present in the parent Namespace. The object itself counts as present, so the setters rejected the value the object already had and, in a NamespaceSet that ignores the case, a rename that only changes the case. `Referable.id_short` returns early for its current value, these two did not. `update_from()` worked around that by assigning `type` and `name` only if they differ. That check never ran for the NamespaceSets of the SDK, which all match case-sensitively, and raised a `KeyError` in the one case where it did run. The setters now leave the current value alone and compare a duplicate with the object itself, so `update_from()` assigns `type` and `name` unconditionally. A value that belongs to another object is still rejected, and since that is the first step of `update_from()`, nothing else is changed in that case. --- sdk/basyx/aas/model/base.py | 16 ++-- sdk/test/model/test_base.py | 172 +++++++++++++++++++++++++++++++++++- 2 files changed, 178 insertions(+), 10 deletions(-) diff --git a/sdk/basyx/aas/model/base.py b/sdk/basyx/aas/model/base.py index 4764ec736..aee7bb986 100644 --- a/sdk/basyx/aas/model/base.py +++ b/sdk/basyx/aas/model/base.py @@ -1770,8 +1770,10 @@ def name(self): def name(self, name: NameType) -> None: _string_constraints.check_name_type(name) if self.parent is not None: + if name == self._name: + return for set_ in self.parent.namespace_element_sets: - if set_.contains_id("name", name): + if set_.contains_id("name", name) and set_.get("name", name) is not self: raise KeyError( "Object with name '{}' is already present in the parent Namespace".format( name @@ -1797,9 +1799,7 @@ def update_from(self, other: "Extension") -> None: :param other: The object to update from """ - # The name setter rejects the current name as a duplicate, so only assign a changed name - if self.name != other.name: - self.name = other.name + self.name = other.name self._update_semantic_ids_from(other) # Assign value_type first so the incoming value is cast against the new type. self.value_type = other.value_type @@ -1944,8 +1944,10 @@ def type(self): def type(self, type_: QualifierType) -> None: _string_constraints.check_qualifier_type(type_) if self.parent is not None: + if type_ == self._type: + return for set_ in self.parent.namespace_element_sets: - if set_.contains_id("type", type_): + if set_.contains_id("type", type_) and set_.get("type", type_) is not self: raise KeyError( "Object with type '{}' is already present in the parent Namespace".format( type_ @@ -1971,9 +1973,7 @@ def update_from(self, other: "Qualifier") -> None: :param other: The object to update from """ - # The type setter rejects the current type as a duplicate, so only assign a changed type - if self.type != other.type: - self.type = other.type + self.type = other.type self._update_semantic_ids_from(other) # Assign value_type first so the incoming value is cast against the new type. self.value_type = other.value_type diff --git a/sdk/test/model/test_base.py b/sdk/test/model/test_base.py index b15b2c5fb..f838f9fd5 100644 --- a/sdk/test/model/test_base.py +++ b/sdk/test/model/test_base.py @@ -419,7 +419,7 @@ def __init__(self, values=()): class ExampleNamespaceExtension(model.HasExtension): def __init__(self, values=()): super().__init__() - self.set1 = model.NamespaceSet(self, [("name", True)], values) + self.set1 = model.NamespaceSet(self, [("name", False)], values) class FailingUpdateProperty(model.Property): @@ -860,6 +860,11 @@ def test_renaming(self) -> None: self.assertIn("already present", str(cm.exception)) self.extension1.name = "Ext3" self.assertEqual(self.extension1.name, "Ext3") + # The current name is no duplicate of the object itself, and setting it again changes nothing + order = list(self.namespace.set3) + self.extension2.name = "Ext2" + self.assertIs(self.extension2, self.namespace.set3.get("name", "Ext2")) + self.assertEqual(order, list(self.namespace.set3)) self.namespace.set4.add(self.qualifier1) self.namespace.set4.add(self.qualifier2) @@ -868,6 +873,50 @@ def test_renaming(self) -> None: self.assertIn("already present", str(cm.exception)) self.qualifier1.type = "type3" self.assertEqual(self.qualifier1.type, "type3") + order = list(self.namespace.set4) + self.qualifier2.type = "type2" + self.assertIs(self.qualifier2, self.namespace.set4.get("type", "type2")) + self.assertEqual(order, list(self.namespace.set4)) + + def test_renaming_ignoring_case(self) -> None: + # In a NamespaceSet that ignores the case, the old spelling of the object itself is no duplicate, + # but the spelling of another object still is + test_cases: List[Tuple[Any, Any, Any, str, str, str]] = [ + ( + self._namespace_class_qualifier, + model.Qualifier("Case", model.datatypes.Int, 1), + model.Qualifier("Other", model.datatypes.Int, 2), + "type", + "CASE", + "case", + ), + ( + self._namespace_class_extension, + model.Extension("Case", model.datatypes.Int, 1), + model.Extension("Other", model.datatypes.Int, 2), + "name", + "CASE", + "case", + ), + ] + for namespace_class, object1, object2, attr_name, upper, lower in test_cases: + with self.subTest(type(object1).__name__): + namespace = namespace_class() + namespace.set1.add(object1) + namespace.set1.add(object2) + + setattr(object1, attr_name, upper) + self.assertEqual(upper, getattr(object1, attr_name)) + self.assertIs( + object1, namespace.set1.get_object_by_attribute(attr_name, lower) + ) + self.assertEqual(2, len(namespace.set1)) + self.assertIs(object1.parent, namespace) + + with self.assertRaises(KeyError) as cm: + setattr(object2, attr_name, lower) + self.assertIn("already present", str(cm.exception)) + self.assertEqual("Other", getattr(object2, attr_name)) def test_Namespaceset_update_from(self) -> None: # Prop1 is getting its value updated by namespace2.set1 @@ -1001,6 +1050,47 @@ def test_Namespaceset_update_from(self) -> None: self.assertFalse(extension_ns1.set1.contains_id("name", "Ext2")) self.assertIsNone(extension2.parent) + def test_Namespaceset_update_from_case_insensitive_match(self) -> None: + # The sets match the objects regardless of the case, so the type or name of the other object may be spelled + # differently. The object in this set stays the same object and takes over the spelling of the other one. + test_cases: List[Tuple[Any, Any, Any, str]] = [ + ( + self._namespace_class_qualifier, + model.Qualifier("type1", model.datatypes.Int, 1), + model.Qualifier("TYPE1", model.datatypes.Int, 99), + "type", + ), + ( + self._namespace_class_extension, + model.Extension("Ext1", model.datatypes.Int, 1), + model.Extension("EXT1", model.datatypes.Int, 99), + "name", + ), + ] + for namespace_class, old_object, new_object, attr_name in test_cases: + with self.subTest(type(new_object).__name__): + namespace1 = namespace_class() + namespace2 = namespace_class() + namespace1.set1.add(old_object) + namespace2.set1.add(new_object) + + namespace1.set1.update_nss_from(namespace2.set1) + self.assertEqual( + getattr(new_object, attr_name), getattr(old_object, attr_name) + ) + self.assertEqual(99, old_object.value) + self.assertEqual([old_object], list(namespace1.set1)) + self.assertIs(old_object.parent, namespace1) + self.assertIs( + old_object, + namespace1.set1.get_object_by_attribute( + attr_name, getattr(old_object, attr_name) + ), + ) + # The other set is left alone + self.assertEqual([new_object], list(namespace2.set1)) + self.assertIs(new_object.parent, namespace2) + def test_Namespaceset_update_from_key_error(self) -> None: # A KeyError raised by update_from() has to reach the caller. It does not tell that the object is missing in # the NamespaceSet, so nothing may be moved from the other set into this one. @@ -1104,7 +1194,7 @@ def __init__(self, values=()): class ExampleOrderedNamespaceExtension(model.HasExtension): def __init__(self, values=()): super().__init__() - self.set1 = model.OrderedNamespaceSet(self, [("name", True)], values) + self.set1 = model.OrderedNamespaceSet(self, [("name", False)], values) class ModelOrderedNamespaceTest(ModelNamespaceTest): @@ -1708,6 +1798,48 @@ def test_update_from(self) -> None: ) self.assertIs(submodel.get_qualifier_by_type("q"), qualifier) + def test_update_from_type_collision(self) -> None: + # The type of the other object belongs to another qualifier, which must not change anything + submodel = model.Submodel("urn:x-test:submodel") + qualifier = model.Qualifier( + "q1", + model.datatypes.String, + "old", + value_id=_global_reference("urn:x-test:value-id"), + kind=model.QualifierKind.CONCEPT_QUALIFIER, + semantic_id=_global_reference("urn:x-test:semantic-id"), + supplemental_semantic_id=[_global_reference("urn:x-test:supplemental")], + ) + other_qualifier = model.Qualifier("q2", model.datatypes.String, "other") + submodel.add_qualifier(qualifier) + submodel.add_qualifier(other_qualifier) + + with self.assertRaises(KeyError) as cm: + qualifier.update_from( + model.Qualifier( + "q2", + model.datatypes.Int, + 2, + value_id=_global_reference("urn:x-test:new-value-id"), + kind=model.QualifierKind.VALUE_QUALIFIER, + ) + ) + self.assertIn("already present", str(cm.exception)) + self.assertEqual("q1", qualifier.type) + self.assertIs(qualifier.value_type, model.datatypes.String) + self.assertEqual("old", qualifier.value) + self.assertEqual(_global_reference("urn:x-test:value-id"), qualifier.value_id) + self.assertEqual(model.QualifierKind.CONCEPT_QUALIFIER, qualifier.kind) + self.assertEqual( + _global_reference("urn:x-test:semantic-id"), qualifier.semantic_id + ) + self.assertEqual( + [_global_reference("urn:x-test:supplemental")], + list(qualifier.supplemental_semantic_id), + ) + self.assertIs(submodel.get_qualifier_by_type("q1"), qualifier) + self.assertIs(submodel.get_qualifier_by_type("q2"), other_qualifier) + def test_update_from_without_semantic_id(self) -> None: # Constraint AASd-118: The semantic_id must not be removed while there is a supplemental_semantic_id submodel = model.Submodel("urn:x-test:submodel") @@ -1787,6 +1919,42 @@ def test_update_from(self) -> None: ) self.assertIs(submodel.get_extension_by_name("e"), extension) + def test_update_from_name_collision(self) -> None: + # The name of the other object belongs to another extension, which must not change anything + submodel = model.Submodel("urn:x-test:submodel") + refers_to = model.ModelReference( + (model.Key(model.KeyTypes.SUBMODEL, "urn:x-test:submodel"),), + model.Submodel, + ) + extension = model.Extension( + "e1", + model.datatypes.String, + "old", + refers_to=[refers_to], + semantic_id=_global_reference("urn:x-test:semantic-id"), + supplemental_semantic_id=[_global_reference("urn:x-test:supplemental")], + ) + other_extension = model.Extension("e2", model.datatypes.String, "other") + submodel.add_extension(extension) + submodel.add_extension(other_extension) + + with self.assertRaises(KeyError) as cm: + extension.update_from(model.Extension("e2", model.datatypes.Int, 2)) + self.assertIn("already present", str(cm.exception)) + self.assertEqual("e1", extension.name) + self.assertIs(extension.value_type, model.datatypes.String) + self.assertEqual("old", extension.value) + self.assertEqual({refers_to}, extension.refers_to) + self.assertEqual( + _global_reference("urn:x-test:semantic-id"), extension.semantic_id + ) + self.assertEqual( + [_global_reference("urn:x-test:supplemental")], + list(extension.supplemental_semantic_id), + ) + self.assertIs(submodel.get_extension_by_name("e1"), extension) + self.assertIs(submodel.get_extension_by_name("e2"), other_extension) + def test_update_from_without_semantic_id(self) -> None: # Constraint AASd-118: The semantic_id must not be removed while there is a supplemental_semantic_id submodel = model.Submodel("urn:x-test:submodel")