Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

model.base: add id_short path resolution #249

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 47 additions & 35 deletions basyx/aas/model/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:]))
Comment on lines +1054 to +1055
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very elegant but indeed really hard to understand.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the map(lambda... expression is pretty clean, but calling get_referable() and manually supplying self as item, because item is not necessarily a UniqueIdShortNamespace, is not. However, I think it's acceptable because get_referable() checks the type of self in the first iteration and because it's all documented via comments, in ModelReference.resolve() as well as UniqueIdShortNamespace.get_referable(). It's the only solution I could come up with that doesn't require duplicate error messages, i.e. having the same check with the same error message in both functions.


# Check type
if not isinstance(item, self.type):
Expand Down Expand Up @@ -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):
s-heppner marked this conversation as resolved.
Show resolved Hide resolved
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:
"""
Expand Down
74 changes: 57 additions & 17 deletions test/model/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)])
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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"
Expand Down Expand Up @@ -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)])
Expand Down Expand Up @@ -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))


Expand Down Expand Up @@ -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"),
Expand All @@ -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"),
Expand All @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
Loading