diff --git a/basyx/aas/model/base.py b/basyx/aas/model/base.py index 51112c9b5..332161dcb 100644 --- a/basyx/aas/model/base.py +++ b/basyx/aas/model/base.py @@ -503,7 +503,7 @@ def _get_object(self, object_type: Type[_NSO], attribute_name: str, attribute) - return ns_set.get_object_by_attribute(attribute_name, attribute) except KeyError: continue - raise KeyError(f"{object_type.__name__} with {attribute_name} {attribute} not found in this namespace") + raise KeyError(f"{object_type.__name__} with {attribute_name} {attribute} not found in {self!r}") def _add_object(self, attribute_name: str, obj: _NSO) -> None: """ @@ -531,7 +531,7 @@ def _remove_object(self, object_type: type, attribute_name: str, attribute) -> N return except KeyError: continue - raise KeyError(f"{object_type.__name__} with {attribute_name} {attribute} not found in this namespace") + raise KeyError(f"{object_type.__name__} with {attribute_name} {attribute} not found in {self!r}") class HasExtension(Namespace, metaclass=abc.ABCMeta): @@ -866,7 +866,7 @@ def _direct_source_commit(self): class UnexpectedTypeError(TypeError): """ - Exception to be raised by :meth:`basyx.aas.model.base.ModelReference.resolve` if the retrieved object has not + Exception to be raised by :meth:`.ModelReference.resolve` if the retrieved object has not the expected type. :ivar value: The object of unexpected type @@ -1029,48 +1029,30 @@ def resolve(self, provider_: "provider.AbstractObjectProvider") -> _RT: :return: The referenced object (or a proxy object for it) :raises IndexError: If the list of keys is empty :raises TypeError: If one of the intermediate objects on the path is not a - :class:`~basyx.aas.model.base.Namespace` + :class:`~.UniqueIdShortNamespace` + :raises ValueError: If a non-numeric index is given to resolve in a + :class:`~basyx.aas.model.submodel.SubmodelElementList` :raises UnexpectedTypeError: If the retrieved object is not of the expected type (or one of its subclasses). The object is stored in the ``value`` attribute of the exception :raises KeyError: If the reference could not be resolved """ - from . import SubmodelElementList - # For ModelReferences, the first key must be an AasIdentifiable. So resolve the first key via the provider. identifier: Optional[Identifier] = self.key[0].get_identifier() if identifier is None: - raise AssertionError("Retrieving the identifier of the first key failed.") + raise AssertionError(f"Retrieving the identifier of the first {self.key[0]!r} failed.") - resolved_keys: List[str] = [] # for more helpful error messages try: item: Referable = provider_.get_identifiable(identifier) except KeyError as e: raise KeyError("Could not resolve identifier {}".format(identifier)) from e - resolved_keys.append(str(identifier)) - # All keys following the first must not reference identifiables (AASd-125). Thus, we can just follow the path - # recursively. - for key in self.key[1:]: - if not isinstance(item, UniqueIdShortNamespace): - raise TypeError("Object retrieved at {} is not a Namespace".format(" / ".join(resolved_keys))) - is_submodel_element_list = isinstance(item, SubmodelElementList) - try: - if is_submodel_element_list: - # The key's value must be numeric, since this is checked for keys following keys of type - # SUBMODEL_ELEMENT_LIST on construction of ModelReferences. - # Additionally item is known to be a SubmodelElementList which supports __getitem__ because we're in - # the `is_submodel_element_list` branch, but mypy doesn't infer types based on isinstance checks - # stored in boolean variables. - item = item.value[int(key.value)] # type: ignore - resolved_keys[-1] += f"[{key.value}]" - else: - item = item.get_referable(key.value) - resolved_keys.append(item.id_short) - except (KeyError, IndexError) as e: - raise KeyError("Could not resolve {} {} at {}".format( - "index" if is_submodel_element_list else "id_short", key.value, " / ".join(resolved_keys)))\ - from e + # All keys following the first must not reference identifiables (AASd-125). Thus, we can just resolve the + # id_short path via get_referable(). + # This is cursed af, but at least it keeps the code DRY. get_referable() will check the type of self in the + # first iteration, so we can ignore the type here. + item = UniqueIdShortNamespace.get_referable(item, # type: ignore[arg-type] + map(lambda k: k.value, self.key[1:])) # Check type if not isinstance(item, self.type): @@ -1734,15 +1716,45 @@ def __init__(self) -> None: super().__init__() self.namespace_element_sets: List[NamespaceSet] = [] - def get_referable(self, id_short: NameType) -> Referable: + def get_referable(self, id_short: Union[NameType, Iterable[NameType]]) -> Referable: """ - Find a :class:`~.Referable` in this Namespace by its id_short + 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 + :param id_short: id_short or id_short path as any :class:`Iterable` :returns: :class:`~.Referable` + :raises TypeError: If one of the intermediate objects on the path is not a + :class:`~.UniqueIdShortNamespace` + :raises ValueError: If a non-numeric index is given to resolve in a + :class:`~basyx.aas.model.submodel.SubmodelElementList` :raises KeyError: If no such :class:`~.Referable` can be found """ - return super()._get_object(Referable, "id_short", id_short) # type: ignore + from .submodel import SubmodelElementList + if isinstance(id_short, NameType): + id_short = [id_short] + item: Union[UniqueIdShortNamespace, Referable] = self + for id_ in id_short: + # This is redundant on first iteration, but it's a negligible overhead. + # Also, ModelReference.resolve() relies on this check. + if not isinstance(item, UniqueIdShortNamespace): + raise TypeError(f"Cannot resolve id_short or index '{id_}' at {item!r}, " + f"because it is not a {UniqueIdShortNamespace.__name__}!") + is_submodel_element_list = isinstance(item, SubmodelElementList) + try: + if is_submodel_element_list: + # item is known to be a SubmodelElementList which supports __getitem__ because we're in + # the `is_submodel_element_list` branch, but mypy doesn't infer types based on isinstance checks + # stored in boolean variables. + item = item.value[int(id_)] # type: ignore + else: + item = item._get_object(Referable, "id_short", id_) # type: ignore[type-abstract] + except ValueError as e: + raise ValueError(f"Cannot resolve '{id_}' at {item!r}, because it is not a numeric index!") from e + except (KeyError, IndexError) as e: + raise KeyError("Referable with {} {} not found in {}".format( + "index" if is_submodel_element_list else "id_short", id_, repr(item))) from e + # All UniqueIdShortNamespaces are Referables, and we only ever assign Referable to item. + return item # type: ignore[return-value] def add_referable(self, referable: Referable) -> None: """ diff --git a/test/model/test_base.py b/test/model/test_base.py index 44ac6861d..21ae1f13d 100644 --- a/test/model/test_base.py +++ b/test/model/test_base.py @@ -323,9 +323,11 @@ def test_update_commit_qualifier_extension_semantic_id(self): submodel.commit() -class ExampleNamespaceReferable(model.UniqueIdShortNamespace, model.UniqueSemanticIdNamespace): +class ExampleNamespaceReferable(model.UniqueIdShortNamespace, model.UniqueSemanticIdNamespace, model.Identifiable): def __init__(self, values=()): super().__init__() + # The 'id' is required by Referable.__repr__() in error messages. + self.id = self.__class__.__name__ self.set1 = model.NamespaceSet(self, [("id_short", False), ("semantic_id", True)]) self.set2 = model.NamespaceSet(self, [("id_short", False)], values) self.set3 = model.NamespaceSet(self, [("name", True)]) @@ -358,6 +360,9 @@ def setUp(self): self.prop7 = model.Property("Prop2", model.datatypes.Int, semantic_id=self.propSemanticID3) self.prop8 = model.Property("ProP2", model.datatypes.Int, semantic_id=self.propSemanticID3) self.prop1alt = model.Property("Prop1", model.datatypes.Int, semantic_id=self.propSemanticID) + self.collection1 = model.SubmodelElementCollection(None) + self.list1 = model.SubmodelElementList("List1", model.SubmodelElementCollection, + semantic_id=self.propSemanticID) self.qualifier1 = model.Qualifier("type1", model.datatypes.Int, 1, semantic_id=self.propSemanticID) self.qualifier2 = model.Qualifier("type2", model.datatypes.Int, 1, semantic_id=self.propSemanticID2) self.qualifier1alt = model.Qualifier("type1", model.datatypes.Int, 1, semantic_id=self.propSemanticID) @@ -572,17 +577,42 @@ def test_Namespace(self) -> None: self.assertIs(self.prop2, namespace.get_referable("Prop2")) with self.assertRaises(KeyError) as cm2: namespace.get_referable("Prop3") - self.assertEqual("'Referable with id_short Prop3 not found in this namespace'", - str(cm2.exception)) + self.assertEqual("'Referable with id_short Prop3 not found in " + f"{self._namespace_class.__name__}[{self.namespace.id}]'", str(cm2.exception)) namespace.remove_referable("Prop2") with self.assertRaises(KeyError) as cm3: namespace.get_referable("Prop2") - self.assertEqual("'Referable with id_short Prop2 not found in this namespace'", str(cm3.exception)) + self.assertEqual("'Referable with id_short Prop2 not found in " + f"{self._namespace_class.__name__}[{self.namespace.id}]'", str(cm3.exception)) with self.assertRaises(KeyError) as cm4: namespace.remove_referable("Prop2") - self.assertEqual("'Referable with id_short Prop2 not found in this namespace'", str(cm4.exception)) + self.assertEqual("'Referable with id_short Prop2 not found in " + f"{self._namespace_class.__name__}[{self.namespace.id}]'", str(cm4.exception)) + + def test_id_short_path_resolution(self) -> None: + self.namespace.set2.add(self.list1) + self.list1.add_referable(self.collection1) + self.collection1.add_referable(self.prop1) + + with self.assertRaises(ValueError) as cm: + self.namespace.get_referable(["List1", "a"]) + self.assertEqual(f"Cannot resolve 'a' at SubmodelElementList[{self.namespace.id} / List1], " + "because it is not a numeric index!", str(cm.exception)) + + with self.assertRaises(KeyError) as cm_2: + self.namespace.get_referable(["List1", "0", "Prop2"]) + self.assertEqual("'Referable with id_short Prop2 not found in " + f"SubmodelElementCollection[{self.namespace.id} / List1[0]]'", str(cm_2.exception)) + + 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], " + "because it is not a UniqueIdShortNamespace!", str(cm_3.exception)) + + self.namespace.get_referable(["List1", "0", "Prop1"]) def test_renaming(self) -> None: self.namespace.set2.add(self.prop1) @@ -596,8 +626,8 @@ def test_renaming(self) -> None: self.assertIs(self.prop1, self.namespace.get_referable("Prop3")) with self.assertRaises(KeyError) as cm: self.namespace.get_referable('Prop1') - self.assertEqual("'Referable with id_short Prop1 not found in this namespace'", - str(cm.exception)) + self.assertEqual("'Referable with id_short Prop1 not found in " + f"{self._namespace_class.__name__}[{self.namespace.id}]'", str(cm.exception)) self.assertIs(self.prop2, self.namespace.get_referable("Prop2")) with self.assertRaises(model.AASConstraintViolation) as cm2: self.prop1.id_short = "Prop2" @@ -671,9 +701,11 @@ def test_aasd_117(self) -> None: property.id_short = "bar" -class ExampleOrderedNamespace(model.UniqueIdShortNamespace, model.UniqueSemanticIdNamespace): +class ExampleOrderedNamespace(model.UniqueIdShortNamespace, model.UniqueSemanticIdNamespace, model.Identifiable): def __init__(self, values=()): super().__init__() + # The 'id' is required by Referable.__repr__() in error messages. + self.id = self.__class__.__name__ self.set1 = model.OrderedNamespaceSet(self, [("id_short", False), ("semantic_id", True)]) self.set2 = model.OrderedNamespaceSet(self, [("id_short", False)], values) self.set3 = model.NamespaceSet(self, [("name", True)]) @@ -724,7 +756,8 @@ def test_OrderedNamespace(self) -> None: self.assertEqual(1, len(namespace2.set2)) with self.assertRaises(KeyError) as cm2: namespace2.get_referable("Prop1") - self.assertEqual("'Referable with id_short Prop1 not found in this namespace'", + self.assertEqual("'Referable with id_short Prop1 not found in " + f"{self._namespace_class.__name__}[{self.namespace.id}]'", # type: ignore[has-type] str(cm2.exception)) @@ -887,7 +920,7 @@ def get_identifiable(self, identifier: Identifier) -> Identifiable: model.Property) with self.assertRaises(KeyError) as cm: ref1.resolve(DummyObjectProvider()) - self.assertEqual("'Could not resolve id_short lst at urn:x-test:submodel'", str(cm.exception)) + self.assertEqual("'Referable with id_short lst not found in Submodel[urn:x-test:submodel]'", str(cm.exception)) ref2 = model.ModelReference((model.Key(model.KeyTypes.SUBMODEL, "urn:x-test:submodel"), model.Key(model.KeyTypes.SUBMODEL_ELEMENT_LIST, "list"), @@ -896,7 +929,8 @@ def get_identifiable(self, identifier: Identifier) -> Identifiable: model.Property) with self.assertRaises(KeyError) as cm_2: ref2.resolve(DummyObjectProvider()) - self.assertEqual("'Could not resolve index 99 at urn:x-test:submodel / list'", str(cm_2.exception)) + self.assertEqual("'Referable with index 99 not found in SubmodelElementList[urn:x-test:submodel / list]'", + str(cm_2.exception)) ref3 = model.ModelReference((model.Key(model.KeyTypes.SUBMODEL, "urn:x-test:submodel"), model.Key(model.KeyTypes.SUBMODEL_ELEMENT_LIST, "list"), @@ -913,8 +947,8 @@ def get_identifiable(self, identifier: Identifier) -> Identifiable: model.Property) with self.assertRaises(TypeError) as cm_3: ref4.resolve(DummyObjectProvider()) - self.assertEqual("Object retrieved at urn:x-test:submodel / list[0] / prop is not a Namespace", - str(cm_3.exception)) + 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: ref1.key[2].value = "prop1" @@ -944,12 +978,18 @@ def get_identifiable(self, identifier: Identifier) -> Identifiable: with self.assertRaises(KeyError) as cm_8: ref8.resolve(DummyObjectProvider()) - self.assertEqual("'Could not resolve id_short prop_false at urn:x-test:submodel / list[0]'", - str(cm_8.exception)) + self.assertEqual("'Referable with id_short prop_false not found in " + "SubmodelElementCollection[urn:x-test:submodel / list[0]]'", str(cm_8.exception)) + + ref9 = model.ModelReference((model.Key(model.KeyTypes.SUBMODEL, "urn:x-test:submodel"), + model.Key(model.KeyTypes.SUBMODEL_ELEMENT_COLLECTION, "list"), + model.Key(model.KeyTypes.SUBMODEL_ELEMENT_COLLECTION, "collection")), + model.SubmodelElementCollection) with self.assertRaises(ValueError) as cm_9: - ref9 = model.ModelReference((), model.Submodel) - self.assertEqual('A reference must have at least one key!', str(cm_9.exception)) + ref9.resolve(DummyObjectProvider()) + self.assertEqual("Cannot resolve 'collection' at SubmodelElementList[urn:x-test:submodel / list], " + "because it is not a numeric index!", str(cm_9.exception)) def test_get_identifier(self) -> None: ref = model.ModelReference((model.Key(model.KeyTypes.SUBMODEL, "urn:x-test:x"),), model.Submodel)