diff --git a/sdk/basyx/aas/adapter/json/json_serialization.py b/sdk/basyx/aas/adapter/json/json_serialization.py index 024226d97..0b0df0164 100644 --- a/sdk/basyx/aas/adapter/json/json_serialization.py +++ b/sdk/basyx/aas/adapter/json/json_serialization.py @@ -136,7 +136,7 @@ def _abstract_classes_to_json(cls, obj: object) -> Dict[str, object]: if obj.description: data['description'] = obj.description try: - ref_type = next(iter(t for t in inspect.getmro(type(obj)) if t in model.KEY_TYPES_CLASSES)) + ref_type = model.resolve_referable_class_in_key_types(obj) except StopIteration as e: raise TypeError("Object of type {} is Referable but does not inherit from a known AAS type" .format(obj.__class__.__name__)) from e diff --git a/sdk/basyx/aas/model/__init__.py b/sdk/basyx/aas/model/__init__.py index e541968b5..2ddd93b51 100644 --- a/sdk/basyx/aas/model/__init__.py +++ b/sdk/basyx/aas/model/__init__.py @@ -39,3 +39,17 @@ RelationshipElement: KeyTypes.RELATIONSHIP_ELEMENT, SubmodelElement: KeyTypes.SUBMODEL_ELEMENT, # type: ignore } + + +def resolve_referable_class_in_key_types(referable: Referable) -> type: + """ + Returns the type of referable if the type is given in `KEY_TYPES_CLASSES`, otherwise return the first parent class + in inheritance chain of the referable which is given in `KEY_TYPES_CLASSES`. + + :raises TypeError: If the type of the referable or any of its parent classes is not given in `KEY_TYPES_CLASSES`. + """ + try: + ref_type = next(iter(t for t in inspect.getmro(type(referable)) if t in KEY_TYPES_CLASSES)) + except StopIteration: + raise TypeError(f"Could not find a matching class in KEY_TYPES_CLASSES for {type(referable)}") + return ref_type diff --git a/sdk/basyx/aas/model/base.py b/sdk/basyx/aas/model/base.py index 60b6d43fb..35ccad5a1 100644 --- a/sdk/basyx/aas/model/base.py +++ b/sdk/basyx/aas/model/base.py @@ -41,6 +41,8 @@ VersionType = str ValueTypeIEC61360 = str +MAX_RECURSION_DEPTH = 32*2 # see https://github.com/admin-shell-io/aas-specs-metamodel/issues/333 + @unique class KeyTypes(Enum): @@ -453,25 +455,31 @@ def from_referable(referable: "Referable") -> "Key": """ # Get the `type` by finding the first class from the base classes list (via inspect.getmro), that is contained # in KEY_ELEMENTS_CLASSES - from . import KEY_TYPES_CLASSES, SubmodelElementList - try: - key_type = next(iter(KEY_TYPES_CLASSES[t] - for t in inspect.getmro(type(referable)) - if t in KEY_TYPES_CLASSES)) - except StopIteration: - key_type = KeyTypes.PROPERTY + key_type = Key._get_key_type_for_referable(referable) + key_value = Key._get_key_value_for_referable(referable) + return Key(key_type, key_value) + @staticmethod + def _get_key_type_for_referable(referable: "Referable") -> KeyTypes: + from . import KEY_TYPES_CLASSES, resolve_referable_class_in_key_types + ref_type = resolve_referable_class_in_key_types(referable) + key_type = KEY_TYPES_CLASSES[ref_type] + return key_type + + @staticmethod + def _get_key_value_for_referable(referable: "Referable") -> str: + from . import SubmodelElementList if isinstance(referable, Identifiable): - return Key(key_type, referable.id) + return referable.id elif isinstance(referable.parent, SubmodelElementList): try: - return Key(key_type, str(referable.parent.value.index(referable))) # type: ignore + return str(referable.parent.value.index(referable)) # type: ignore except ValueError as e: raise ValueError(f"Object {referable!r} is not contained within its parent {referable.parent!r}") from e else: if referable.id_short is None: - raise ValueError(f"Can't create Key for {referable!r} without an id_short!") - return Key(key_type, referable.id_short) + raise ValueError(f"Can't create Key value for {referable!r} without an id_short!") + return referable.id_short _NSO = TypeVar('_NSO', bound=Union["Referable", "Qualifier", "HasSemantics", "Extension"]) @@ -614,26 +622,75 @@ def __init__(self): self.parent: Optional[UniqueIdShortNamespace] = None def __repr__(self) -> str: - reversed_path = [] + root = self.get_identifiable_root() + try: + id_short_path = self.get_id_short_path() + except (ValueError, AttributeError): + id_short_path = self.id_short if self.id_short is not None else "" + item_cls_name = self.__class__.__name__ + + if root is None: + item_path = f"[{id_short_path}]" if id_short_path else "" + else: + item_path = f"[{root.id} / {id_short_path}]" if id_short_path else f"[{root.id}]" + + return f"{item_cls_name}{item_path}" + + def get_identifiable_root(self) -> Optional["Identifiable"]: + """ + Get the root :class:`~.Identifiable` of this referable, if it exists. + + :return: The root :class:`~.Identifiable` or None if no such root exists + """ item = self # type: Any - if item.id_short is not None: - from .submodel import SubmodelElementList - while item is not None: - if isinstance(item, Identifiable): - reversed_path.append(item.id) - break - elif isinstance(item, Referable): - if isinstance(item.parent, SubmodelElementList): - reversed_path.append(f"{item.parent.id_short}[{item.parent.value.index(item)}]") - item = item.parent - else: - reversed_path.append(item.id_short) - item = item.parent - else: - raise AttributeError('Referable must have an identifiable as root object and only parents that are ' - 'referable') + while item is not None: + if isinstance(item, Identifiable): + return item + elif isinstance(item, Referable): + item = item.parent + else: + raise AttributeError('Referable must have an identifiable as root object and only parents that are ' + 'referable') + return None - return self.__class__.__name__ + ("[{}]".format(" / ".join(reversed(reversed_path))) if reversed_path else "") + def get_id_short_path(self) -> str: + """ + Get the id_short path of this referable, i.e. the id_short of this referable and all its parents. + + :return: The id_short path as a string, e.g. "MySECollection.MySEList[2]MySubProperty1" + """ + path_list = self.get_id_short_path_as_a_list() + return self.build_id_short_path(path_list) + + def get_id_short_path_as_a_list(self) -> List[str]: + """ + Get the id_short path of this referable as a list of id_shorts and indexes. + + :return: The id_short path as a list, e.g. '["MySECollection", "MySEList", "2", "MySubProperty1"]' + :raises ValueError: If this referable has no id_short or + if its parent is not a :class:`~basyx.aas.model.submodel.SubmodelElementList` + :raises AttributeError: If the parent chain is broken, i.e. if a parent is neither a :class:`~.Referable` nor an + :class:`~.Identifiable` + """ + from .submodel import SubmodelElementList + if self.id_short is None and not isinstance(self.parent, SubmodelElementList): + raise ValueError(f"Can't create id_short_path for {self.__class__.__name__} without an id_short or " + f"if its parent is a SubmodelElementList!") + + item = self # type: Any + path: List[str] = [] + while item is not None: + if not isinstance(item, Referable): + raise AttributeError('Referable must have an identifiable as root object and only parents that are ' + 'referable') + if isinstance(item, Identifiable): + break + elif isinstance(item.parent, SubmodelElementList): + path.insert(0, str(item.parent.value.index(item))) + else: + path.insert(0, item.id_short) + item = item.parent + return path def _get_id_short(self) -> Optional[NameType]: return self._id_short @@ -653,6 +710,49 @@ def _set_category(self, category: Optional[NameType]): def _get_category(self) -> Optional[NameType]: return self._category + @classmethod + def parse_id_short_path(cls, id_short_path: str) -> List[str]: + """ + Parse an id_short_path string into a list of id_shorts and indexes. + + :param id_short_path: The id_short_path string, e.g. "MySECollection.MySEList[2]MySubProperty1" + :return: The id_short path as a list, e.g. '["MySECollection", "MySEList", "2", "MySubProperty1"]' + """ + id_shorts_and_indexes = [] + for part in id_short_path.split("."): + id_short = part[0:part.find('[')] if '[' in part else part + id_shorts_and_indexes.append(id_short) + + indexes_part = part.removeprefix(id_short) + if indexes_part: + if not re.fullmatch(r'(?:\[\d+\])+', indexes_part): + raise ValueError(f"Invalid index format in id_short_path: '{id_short_path}', part: '{part}'") + indexes = indexes_part.strip("[]").split("][") + id_shorts_and_indexes.extend(indexes) + cls.validate_id_short_path(id_shorts_and_indexes) + return id_shorts_and_indexes + + @classmethod + def build_id_short_path(cls, id_short_path: Iterable[str]) -> str: + """ + Build an id_short_path string from a list of id_shorts and indexes. + """ + if isinstance(id_short_path, str): + raise ValueError("id_short_path must be an Iterable of strings, not a single string") + path_list_with_dots_and_brackets = [f"[{part}]" if part.isdigit() else f".{part}" for part in id_short_path] + id_short_path = "".join(path_list_with_dots_and_brackets).removeprefix(".") + return id_short_path + + @classmethod + def validate_id_short_path(cls, id_short_path: Union[str, NameType, Iterable[NameType]]): + if isinstance(id_short_path, str): + id_short_path = cls.parse_id_short_path(id_short_path) + for id_short in id_short_path: + if id_short.isdigit(): + # This is an index, skip validation + continue + cls.validate_id_short(id_short) + @classmethod def validate_id_short(cls, id_short: NameType) -> None: """ @@ -1001,22 +1101,24 @@ def from_referable(referable: Referable) -> "ModelReference": object's ancestors """ # Get the first class from the base classes list (via inspect.getmro), that is contained in KEY_ELEMENTS_CLASSES - from . import KEY_TYPES_CLASSES + from . import resolve_referable_class_in_key_types try: - ref_type = next(iter(t for t in inspect.getmro(type(referable)) if t in KEY_TYPES_CLASSES)) + ref_type = resolve_referable_class_in_key_types(referable) except StopIteration: ref_type = Referable ref: Referable = referable keys: List[Key] = [] while True: - keys.append(Key.from_referable(ref)) + keys.insert(0, Key.from_referable(ref)) if isinstance(ref, Identifiable): - keys.reverse() return ModelReference(tuple(keys), ref_type) if ref.parent is None or not isinstance(ref.parent, Referable): - raise ValueError("The given Referable object is not embedded within an Identifiable object") + raise ValueError(f"The given Referable object is not embedded within an Identifiable object: {ref}") ref = ref.parent + if len(keys) > MAX_RECURSION_DEPTH: + raise ValueError(f"The given Referable object is embedded in >64 layers of Referables " + f"or there is a loop in the parent chain {ref}") @_string_constraints.constrain_content_type("content_type") @@ -1624,12 +1726,12 @@ def __init__(self) -> None: super().__init__() self.namespace_element_sets: List[NamespaceSet] = [] - def get_referable(self, id_short: Union[NameType, Iterable[NameType]]) -> Referable: + def get_referable(self, id_short_path: Union[str, NameType, Iterable[NameType]]) -> Referable: """ Find a :class:`~.Referable` in this Namespace by its id_short or by its id_short path. The id_short path may contain :class:`~basyx.aas.model.submodel.SubmodelElementList` indices. - :param id_short: id_short or id_short path as any :class:`Iterable` + :param id_short_path: id_short or id_short path as a str or any :class:`Iterable` :returns: :class:`~.Referable` :raises TypeError: If one of the intermediate objects on the path is not a :class:`~.UniqueIdShortNamespace` @@ -1638,10 +1740,10 @@ def get_referable(self, id_short: Union[NameType, Iterable[NameType]]) -> Refera :raises KeyError: If no such :class:`~.Referable` can be found """ from .submodel import SubmodelElementList - if isinstance(id_short, NameType): - id_short = [id_short] + if isinstance(id_short_path, (str, NameType)): + id_short_path = Referable.parse_id_short_path(id_short_path) item: Union[UniqueIdShortNamespace, Referable] = self - for id_ in id_short: + for id_ in id_short_path: # This is redundant on first iteration, but it's a negligible overhead. # Also, ModelReference.resolve() relies on this check. if not isinstance(item, UniqueIdShortNamespace): diff --git a/sdk/test/examples/test_helpers.py b/sdk/test/examples/test_helpers.py index faca8602b..0257b8bca 100644 --- a/sdk/test/examples/test_helpers.py +++ b/sdk/test/examples/test_helpers.py @@ -227,7 +227,7 @@ def test_submodel_element_collection_checker(self): self.assertEqual("FAIL: Attribute value of SubmodelElementCollection[Collection] must contain 2 " "SubmodelElements (count=1)", repr(next(checker_iterator))) - self.assertEqual("FAIL: Submodel Element Property[Collection / Prop1] must exist ()", + self.assertEqual("FAIL: Submodel Element Property[Collection.Prop1] must exist ()", repr(next(checker_iterator))) collection.add_referable(property) @@ -291,7 +291,7 @@ def test_annotated_relationship_element(self): self.assertEqual("FAIL: Attribute annotation of AnnotatedRelationshipElement[test] must contain 1 DataElements " "(count=0)", repr(next(checker_iterator))) - self.assertEqual("FAIL: Annotation Property[test / ExampleAnnotatedProperty] must exist ()", + self.assertEqual("FAIL: Annotation Property[test.ExampleAnnotatedProperty] must exist ()", repr(next(checker_iterator))) def test_submodel_checker(self): diff --git a/sdk/test/model/test_base.py b/sdk/test/model/test_base.py index 836980025..460bce563 100644 --- a/sdk/test/model/test_base.py +++ b/sdk/test/model/test_base.py @@ -41,7 +41,7 @@ def test_from_referable(self): self.assertEqual(model.Key(model.KeyTypes.MULTI_LANGUAGE_PROPERTY, "0"), model.Key.from_referable(mlp2)) with self.assertRaises(ValueError) as cm: model.Key.from_referable(mlp1) - self.assertEqual("Can't create Key for MultiLanguageProperty without an id_short!", str(cm.exception)) + self.assertEqual("Can't create Key value for MultiLanguageProperty without an id_short!", str(cm.exception)) mlp1.id_short = "mlp1" self.assertEqual(model.Key(model.KeyTypes.MULTI_LANGUAGE_PROPERTY, "mlp1"), model.Key.from_referable(mlp1)) @@ -51,7 +51,7 @@ def __init__(self): super().__init__() -class ExampleRefereableWithNamespace(model.Referable, model.UniqueIdShortNamespace): +class ExampleReferableWithNamespace(model.Referable, model.UniqueIdShortNamespace): def __init__(self): super().__init__() @@ -79,7 +79,7 @@ def generate_example_referable_with_namespace(id_short: model.NameType, :param child: Child to be added to the namespace sets of the Referable :return: The generated Referable """ - referable = ExampleRefereableWithNamespace() + referable = ExampleReferableWithNamespace() referable.id_short = id_short if child: namespace_set = model.NamespaceSet(parent=referable, attribute_names=[("id_short", True)], @@ -135,6 +135,96 @@ def __init__(self, value: model.Referable): self.assertEqual('Referable must have an identifiable as root object and only parents that are referable', str(cm.exception)) + def test_get_identifiable_root(self): + ref_with_no_parent = ExampleReferableWithNamespace() + ref_with_no_parent.id_short = "NotNone" + + identifiable = ExampleIdentifiable() + + ref_child = ExampleReferable() + ref_child.id_short = "Child" + ref_child.parent = identifiable + + list1 = model.SubmodelElementList("List1", model.SubmodelElementList) + list2 = model.SubmodelElementList(None, model.Property, value_type_list_element=model.datatypes.Int) + prop1 = model.Property(None, model.datatypes.Int) + + list1.parent = ref_child + list1.add_referable(list2) + list2.add_referable(prop1) + + self.assertIs(ref_with_no_parent.get_identifiable_root(), None) + self.assertIs(identifiable.get_identifiable_root(), identifiable) + self.assertIs(ref_child.get_identifiable_root(), identifiable) + self.assertIs(list1.get_identifiable_root(), identifiable) + self.assertIs(list2.get_identifiable_root(), identifiable) + self.assertIs(prop1.get_identifiable_root(), identifiable) + + def test_get_id_short_path(self): + """ + Tests the get_id_short_path() method of Referable objects. + + Example structure: + - SMC: MySubmodelElementCollection + - Property: MySubProperty1 + - Property: MySubProperty2 + - SMC: MySubSubmodelElementCollection + - Property: MySubSubProperty1 + - Property: MySubSubProperty2 + - SML: MySubSubmodelElementList1 + - Property: "MySubTestValue1" + - Property: "MySubTestValue2" + - SML: MySubSubmodelElementList2 + - SML: MySubSubmodelElementList3 + - SMC: MySubmodelElementCollectionInSML3 + - Property: "MySubTestValue3" + """ + MySubmodelElementCollection = model.SubmodelElementCollection("MySubmodelElementCollection") + MySubProperty1 = model.Property("MySubProperty1", model.datatypes.String) + MySubProperty2 = model.Property("MySubProperty2", model.datatypes.String) + MySubSubmodelElementCollection = model.SubmodelElementCollection("MySubSubmodelElementCollection") + MySubSubProperty1 = model.Property("MySubSubProperty1", model.datatypes.String) + MySubSubProperty2 = model.Property("MySubSubProperty2", model.datatypes.String) + MySubSubmodelElementList1 = model.SubmodelElementList("MySubSubmodelElementList1", model.Property, + value_type_list_element=model.datatypes.String) + MySubTestValue1 = model.Property(None, model.datatypes.String) + MySubTestValue2 = model.Property(None, model.datatypes.String) + MySubSubmodelElementList2 = model.SubmodelElementList("MySubSubmodelElementList2", model.SubmodelElementList) + MySubSubmodelElementList3 = model.SubmodelElementList(None, model.SubmodelElementCollection) + MySubmodelElementCollectionInSML3 = model.SubmodelElementCollection(None) + MySubTestValue3 = model.Property("MySubTestValue3", model.datatypes.String) + + MySubmodelElementCollection.add_referable(MySubProperty1) + MySubmodelElementCollection.add_referable(MySubProperty2) + MySubmodelElementCollection.add_referable(MySubSubmodelElementCollection) + MySubSubmodelElementCollection.add_referable(MySubSubProperty1) + MySubSubmodelElementCollection.add_referable(MySubSubProperty2) + MySubmodelElementCollection.add_referable(MySubSubmodelElementList1) + MySubSubmodelElementList1.add_referable(MySubTestValue1) + MySubSubmodelElementList1.add_referable(MySubTestValue2) + MySubmodelElementCollection.add_referable(MySubSubmodelElementList2) + MySubSubmodelElementList2.add_referable(MySubSubmodelElementList3) + MySubSubmodelElementList3.add_referable(MySubmodelElementCollectionInSML3) + MySubmodelElementCollectionInSML3.add_referable(MySubTestValue3) + + expected_id_short_paths = { + MySubmodelElementCollection: "MySubmodelElementCollection", + MySubProperty1: "MySubmodelElementCollection.MySubProperty1", + MySubProperty2: "MySubmodelElementCollection.MySubProperty2", + MySubSubmodelElementCollection: "MySubmodelElementCollection.MySubSubmodelElementCollection", + MySubSubProperty1: "MySubmodelElementCollection.MySubSubmodelElementCollection.MySubSubProperty1", + MySubSubProperty2: "MySubmodelElementCollection.MySubSubmodelElementCollection.MySubSubProperty2", + MySubSubmodelElementList1: "MySubmodelElementCollection.MySubSubmodelElementList1", + MySubTestValue1: "MySubmodelElementCollection.MySubSubmodelElementList1[0]", + MySubTestValue2: "MySubmodelElementCollection.MySubSubmodelElementList1[1]", + MySubSubmodelElementList2: "MySubmodelElementCollection.MySubSubmodelElementList2", + MySubSubmodelElementList3: "MySubmodelElementCollection.MySubSubmodelElementList2[0]", + MySubmodelElementCollectionInSML3: "MySubmodelElementCollection.MySubSubmodelElementList2[0][0]", + MySubTestValue3: "MySubmodelElementCollection.MySubSubmodelElementList2[0][0].MySubTestValue3", + } + for referable, expected_path in expected_id_short_paths.items(): + self.assertEqual(referable.get_id_short_path(), expected_path) + def test_update_from(self): example_submodel = example_aas.create_example_submodel() example_relel = example_submodel.get_referable('ExampleRelationshipElement') @@ -481,7 +571,7 @@ def test_id_short_path_resolution(self) -> None: with self.assertRaises(TypeError) as cm_3: self.namespace.get_referable(["List1", "0", "Prop1", "Test"]) self.assertEqual("Cannot resolve id_short or index 'Test' at " - f"Property[{self.namespace.id} / List1[0] / Prop1], " + f"Property[{self.namespace.id} / List1[0].Prop1], " "because it is not a UniqueIdShortNamespace!", str(cm_3.exception)) self.namespace.get_referable(["List1", "0", "Prop1"]) @@ -568,7 +658,7 @@ def test_aasd_117(self) -> None: se_collection.add_referable(property) with self.assertRaises(model.AASConstraintViolation) as cm: property.id_short = None - self.assertEqual("id_short of Property[foo / property] cannot be unset, since it is already contained in " + self.assertEqual("id_short of Property[foo.property] cannot be unset, since it is already contained in " "SubmodelElementCollection[foo] (Constraint AASd-117)", str(cm.exception)) property.id_short = "bar" @@ -819,7 +909,7 @@ def get_identifiable(self, identifier: Identifier) -> Identifiable: model.Property) with self.assertRaises(TypeError) as cm_3: ref4.resolve(DummyObjectProvider()) - self.assertEqual("Cannot resolve id_short or index 'prop' at Property[urn:x-test:submodel / list[0] / prop], " + self.assertEqual("Cannot resolve id_short or index 'prop' at Property[urn:x-test:submodel / list[0].prop], " "because it is not a UniqueIdShortNamespace!", str(cm_3.exception)) with self.assertRaises(AttributeError) as cm_4: @@ -896,13 +986,13 @@ def test_from_referable(self) -> None: submodel.submodel_element.remove(collection) with self.assertRaises(ValueError) as cm: ref3 = model.ModelReference.from_referable(prop) - self.assertEqual("The given Referable object is not embedded within an Identifiable object", str(cm.exception)) + self.assertEqual("The given Referable object is not embedded within an Identifiable object", + str(cm.exception).split(":")[0]) - # Test creating a reference to a custom Referable class - class DummyThing(model.Referable): + # Test creating a reference to a custom SubmodelElement class + class DummyThing(model.SubmodelElement): def __init__(self, id_short: model.NameType): - super().__init__() - self.id_short = id_short + super().__init__(id_short) class DummyIdentifyableNamespace(model.Submodel, model.UniqueIdShortNamespace): def __init__(self, id_: model.Identifier): @@ -913,7 +1003,7 @@ def __init__(self, id_: model.Identifier): identifable_thing = DummyIdentifyableNamespace("urn:x-test:thing") identifable_thing.things.add(thing) ref4 = model.ModelReference.from_referable(thing) - self.assertIs(ref4.type, model.Referable) + self.assertIs(ref4.type, model.SubmodelElement) class AdministrativeInformationTest(unittest.TestCase): diff --git a/server/app/util/converters.py b/server/app/util/converters.py index 0db897cb6..4e37c4702 100644 --- a/server/app/util/converters.py +++ b/server/app/util/converters.py @@ -60,24 +60,20 @@ def to_python(self, value: str) -> model.Identifier: class IdShortPathConverter(werkzeug.routing.UnicodeConverter): """ - A custom Werkzeug URL converter for handling id_short_sep-separated idShort paths. + A custom Werkzeug URL converter for handling dot-separated idShort paths and indexes. This converter joins a list of idShort strings into an id_short_sep-separated path for URLs - (e.g., ["submodel", "element"] -> "submodel.element") and parses incoming URL paths + (e.g., ["submodel", "element", "1"] -> "submodel.element[1]") and parses incoming URL paths back into a list, validating each idShort. - - :cvar id_short_sep: Separator used to join and split idShort segments. """ - id_short_sep = "." def to_url(self, value: List[str]) -> str: - return super().to_url(self.id_short_sep.join(value)) + id_short_path = model.Referable.build_id_short_path(value) + return super().to_url(id_short_path) def to_python(self, value: str) -> List[str]: - id_shorts = super().to_python(value).split(self.id_short_sep) - for id_short in id_shorts: - try: - model.Referable.validate_id_short(id_short) - except (ValueError, model.AASConstraintViolation): - raise BadRequest(f"{id_short} is not a valid id_short!") - return id_shorts + try: + parsed_id_short_path = model.Referable.parse_id_short_path(value) + except (ValueError, model.AASConstraintViolation) as e: + raise BadRequest(f"{value} is not a valid id_short!") from e + return parsed_id_short_path