Skip to content

Commit 457ee51

Browse files
authored
Add Referable Utility Methods and Refactor Key Handling (#410)
This introduces several utility methods and refactorings for `Referable` objects and `Key` handling. `idShortPath` Utility Functions - Add `Referable.get_identifiable_root()` to retrieve the root `Identifiable` object of a referable. - Add `Referable.get_id_short_path()` to generate an `idShortPath`, useful in client/server contexts. - Add `Referable.parse_id_short_path` to handle `idShortPath` strings - Add `Referable.build_id_short_path` to generate `idShortPath` string from list[str] - Add `Referable.validate_id_short_path` to check if the `idShortPath` is correct - Refactored `get_referable`, so that now it can accept `id_short_path` as a string `Key` Handling - Add `find_registered_referable_type_in_key_types_classes()` to encapsulate usage of `KEY_TYPES_CLASSES`. - Refactor `Key.from_referable` to leverage the new method. Tests - Add unit tests for the new `Referable` methods. - Fix minor typos in `test_base.py`. Fixes #414
1 parent f22422c commit 457ee51

File tree

6 files changed

+269
-67
lines changed

6 files changed

+269
-67
lines changed

sdk/basyx/aas/adapter/json/json_serialization.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ def _abstract_classes_to_json(cls, obj: object) -> Dict[str, object]:
136136
if obj.description:
137137
data['description'] = obj.description
138138
try:
139-
ref_type = next(iter(t for t in inspect.getmro(type(obj)) if t in model.KEY_TYPES_CLASSES))
139+
ref_type = model.resolve_referable_class_in_key_types(obj)
140140
except StopIteration as e:
141141
raise TypeError("Object of type {} is Referable but does not inherit from a known AAS type"
142142
.format(obj.__class__.__name__)) from e

sdk/basyx/aas/model/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,17 @@
3939
RelationshipElement: KeyTypes.RELATIONSHIP_ELEMENT,
4040
SubmodelElement: KeyTypes.SUBMODEL_ELEMENT, # type: ignore
4141
}
42+
43+
44+
def resolve_referable_class_in_key_types(referable: Referable) -> type:
45+
"""
46+
Returns the type of referable if the type is given in `KEY_TYPES_CLASSES`, otherwise return the first parent class
47+
in inheritance chain of the referable which is given in `KEY_TYPES_CLASSES`.
48+
49+
:raises TypeError: If the type of the referable or any of its parent classes is not given in `KEY_TYPES_CLASSES`.
50+
"""
51+
try:
52+
ref_type = next(iter(t for t in inspect.getmro(type(referable)) if t in KEY_TYPES_CLASSES))
53+
except StopIteration:
54+
raise TypeError(f"Could not find a matching class in KEY_TYPES_CLASSES for {type(referable)}")
55+
return ref_type

sdk/basyx/aas/model/base.py

Lines changed: 141 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
VersionType = str
4242
ValueTypeIEC61360 = str
4343

44+
MAX_RECURSION_DEPTH = 32*2 # see https://github.com/admin-shell-io/aas-specs-metamodel/issues/333
45+
4446

4547
@unique
4648
class KeyTypes(Enum):
@@ -453,25 +455,31 @@ def from_referable(referable: "Referable") -> "Key":
453455
"""
454456
# Get the `type` by finding the first class from the base classes list (via inspect.getmro), that is contained
455457
# in KEY_ELEMENTS_CLASSES
456-
from . import KEY_TYPES_CLASSES, SubmodelElementList
457-
try:
458-
key_type = next(iter(KEY_TYPES_CLASSES[t]
459-
for t in inspect.getmro(type(referable))
460-
if t in KEY_TYPES_CLASSES))
461-
except StopIteration:
462-
key_type = KeyTypes.PROPERTY
458+
key_type = Key._get_key_type_for_referable(referable)
459+
key_value = Key._get_key_value_for_referable(referable)
460+
return Key(key_type, key_value)
463461

462+
@staticmethod
463+
def _get_key_type_for_referable(referable: "Referable") -> KeyTypes:
464+
from . import KEY_TYPES_CLASSES, resolve_referable_class_in_key_types
465+
ref_type = resolve_referable_class_in_key_types(referable)
466+
key_type = KEY_TYPES_CLASSES[ref_type]
467+
return key_type
468+
469+
@staticmethod
470+
def _get_key_value_for_referable(referable: "Referable") -> str:
471+
from . import SubmodelElementList
464472
if isinstance(referable, Identifiable):
465-
return Key(key_type, referable.id)
473+
return referable.id
466474
elif isinstance(referable.parent, SubmodelElementList):
467475
try:
468-
return Key(key_type, str(referable.parent.value.index(referable))) # type: ignore
476+
return str(referable.parent.value.index(referable)) # type: ignore
469477
except ValueError as e:
470478
raise ValueError(f"Object {referable!r} is not contained within its parent {referable.parent!r}") from e
471479
else:
472480
if referable.id_short is None:
473-
raise ValueError(f"Can't create Key for {referable!r} without an id_short!")
474-
return Key(key_type, referable.id_short)
481+
raise ValueError(f"Can't create Key value for {referable!r} without an id_short!")
482+
return referable.id_short
475483

476484

477485
_NSO = TypeVar('_NSO', bound=Union["Referable", "Qualifier", "HasSemantics", "Extension"])
@@ -614,26 +622,75 @@ def __init__(self):
614622
self.parent: Optional[UniqueIdShortNamespace] = None
615623

616624
def __repr__(self) -> str:
617-
reversed_path = []
625+
root = self.get_identifiable_root()
626+
try:
627+
id_short_path = self.get_id_short_path()
628+
except (ValueError, AttributeError):
629+
id_short_path = self.id_short if self.id_short is not None else ""
630+
item_cls_name = self.__class__.__name__
631+
632+
if root is None:
633+
item_path = f"[{id_short_path}]" if id_short_path else ""
634+
else:
635+
item_path = f"[{root.id} / {id_short_path}]" if id_short_path else f"[{root.id}]"
636+
637+
return f"{item_cls_name}{item_path}"
638+
639+
def get_identifiable_root(self) -> Optional["Identifiable"]:
640+
"""
641+
Get the root :class:`~.Identifiable` of this referable, if it exists.
642+
643+
:return: The root :class:`~.Identifiable` or None if no such root exists
644+
"""
618645
item = self # type: Any
619-
if item.id_short is not None:
620-
from .submodel import SubmodelElementList
621-
while item is not None:
622-
if isinstance(item, Identifiable):
623-
reversed_path.append(item.id)
624-
break
625-
elif isinstance(item, Referable):
626-
if isinstance(item.parent, SubmodelElementList):
627-
reversed_path.append(f"{item.parent.id_short}[{item.parent.value.index(item)}]")
628-
item = item.parent
629-
else:
630-
reversed_path.append(item.id_short)
631-
item = item.parent
632-
else:
633-
raise AttributeError('Referable must have an identifiable as root object and only parents that are '
634-
'referable')
646+
while item is not None:
647+
if isinstance(item, Identifiable):
648+
return item
649+
elif isinstance(item, Referable):
650+
item = item.parent
651+
else:
652+
raise AttributeError('Referable must have an identifiable as root object and only parents that are '
653+
'referable')
654+
return None
635655

636-
return self.__class__.__name__ + ("[{}]".format(" / ".join(reversed(reversed_path))) if reversed_path else "")
656+
def get_id_short_path(self) -> str:
657+
"""
658+
Get the id_short path of this referable, i.e. the id_short of this referable and all its parents.
659+
660+
:return: The id_short path as a string, e.g. "MySECollection.MySEList[2]MySubProperty1"
661+
"""
662+
path_list = self.get_id_short_path_as_a_list()
663+
return self.build_id_short_path(path_list)
664+
665+
def get_id_short_path_as_a_list(self) -> List[str]:
666+
"""
667+
Get the id_short path of this referable as a list of id_shorts and indexes.
668+
669+
:return: The id_short path as a list, e.g. '["MySECollection", "MySEList", "2", "MySubProperty1"]'
670+
:raises ValueError: If this referable has no id_short or
671+
if its parent is not a :class:`~basyx.aas.model.submodel.SubmodelElementList`
672+
:raises AttributeError: If the parent chain is broken, i.e. if a parent is neither a :class:`~.Referable` nor an
673+
:class:`~.Identifiable`
674+
"""
675+
from .submodel import SubmodelElementList
676+
if self.id_short is None and not isinstance(self.parent, SubmodelElementList):
677+
raise ValueError(f"Can't create id_short_path for {self.__class__.__name__} without an id_short or "
678+
f"if its parent is a SubmodelElementList!")
679+
680+
item = self # type: Any
681+
path: List[str] = []
682+
while item is not None:
683+
if not isinstance(item, Referable):
684+
raise AttributeError('Referable must have an identifiable as root object and only parents that are '
685+
'referable')
686+
if isinstance(item, Identifiable):
687+
break
688+
elif isinstance(item.parent, SubmodelElementList):
689+
path.insert(0, str(item.parent.value.index(item)))
690+
else:
691+
path.insert(0, item.id_short)
692+
item = item.parent
693+
return path
637694

638695
def _get_id_short(self) -> Optional[NameType]:
639696
return self._id_short
@@ -653,6 +710,49 @@ def _set_category(self, category: Optional[NameType]):
653710
def _get_category(self) -> Optional[NameType]:
654711
return self._category
655712

713+
@classmethod
714+
def parse_id_short_path(cls, id_short_path: str) -> List[str]:
715+
"""
716+
Parse an id_short_path string into a list of id_shorts and indexes.
717+
718+
:param id_short_path: The id_short_path string, e.g. "MySECollection.MySEList[2]MySubProperty1"
719+
:return: The id_short path as a list, e.g. '["MySECollection", "MySEList", "2", "MySubProperty1"]'
720+
"""
721+
id_shorts_and_indexes = []
722+
for part in id_short_path.split("."):
723+
id_short = part[0:part.find('[')] if '[' in part else part
724+
id_shorts_and_indexes.append(id_short)
725+
726+
indexes_part = part.removeprefix(id_short)
727+
if indexes_part:
728+
if not re.fullmatch(r'(?:\[\d+\])+', indexes_part):
729+
raise ValueError(f"Invalid index format in id_short_path: '{id_short_path}', part: '{part}'")
730+
indexes = indexes_part.strip("[]").split("][")
731+
id_shorts_and_indexes.extend(indexes)
732+
cls.validate_id_short_path(id_shorts_and_indexes)
733+
return id_shorts_and_indexes
734+
735+
@classmethod
736+
def build_id_short_path(cls, id_short_path: Iterable[str]) -> str:
737+
"""
738+
Build an id_short_path string from a list of id_shorts and indexes.
739+
"""
740+
if isinstance(id_short_path, str):
741+
raise ValueError("id_short_path must be an Iterable of strings, not a single string")
742+
path_list_with_dots_and_brackets = [f"[{part}]" if part.isdigit() else f".{part}" for part in id_short_path]
743+
id_short_path = "".join(path_list_with_dots_and_brackets).removeprefix(".")
744+
return id_short_path
745+
746+
@classmethod
747+
def validate_id_short_path(cls, id_short_path: Union[str, NameType, Iterable[NameType]]):
748+
if isinstance(id_short_path, str):
749+
id_short_path = cls.parse_id_short_path(id_short_path)
750+
for id_short in id_short_path:
751+
if id_short.isdigit():
752+
# This is an index, skip validation
753+
continue
754+
cls.validate_id_short(id_short)
755+
656756
@classmethod
657757
def validate_id_short(cls, id_short: NameType) -> None:
658758
"""
@@ -1001,22 +1101,24 @@ def from_referable(referable: Referable) -> "ModelReference":
10011101
object's ancestors
10021102
"""
10031103
# Get the first class from the base classes list (via inspect.getmro), that is contained in KEY_ELEMENTS_CLASSES
1004-
from . import KEY_TYPES_CLASSES
1104+
from . import resolve_referable_class_in_key_types
10051105
try:
1006-
ref_type = next(iter(t for t in inspect.getmro(type(referable)) if t in KEY_TYPES_CLASSES))
1106+
ref_type = resolve_referable_class_in_key_types(referable)
10071107
except StopIteration:
10081108
ref_type = Referable
10091109

10101110
ref: Referable = referable
10111111
keys: List[Key] = []
10121112
while True:
1013-
keys.append(Key.from_referable(ref))
1113+
keys.insert(0, Key.from_referable(ref))
10141114
if isinstance(ref, Identifiable):
1015-
keys.reverse()
10161115
return ModelReference(tuple(keys), ref_type)
10171116
if ref.parent is None or not isinstance(ref.parent, Referable):
1018-
raise ValueError("The given Referable object is not embedded within an Identifiable object")
1117+
raise ValueError(f"The given Referable object is not embedded within an Identifiable object: {ref}")
10191118
ref = ref.parent
1119+
if len(keys) > MAX_RECURSION_DEPTH:
1120+
raise ValueError(f"The given Referable object is embedded in >64 layers of Referables "
1121+
f"or there is a loop in the parent chain {ref}")
10201122

10211123

10221124
@_string_constraints.constrain_content_type("content_type")
@@ -1624,12 +1726,12 @@ def __init__(self) -> None:
16241726
super().__init__()
16251727
self.namespace_element_sets: List[NamespaceSet] = []
16261728

1627-
def get_referable(self, id_short: Union[NameType, Iterable[NameType]]) -> Referable:
1729+
def get_referable(self, id_short_path: Union[str, NameType, Iterable[NameType]]) -> Referable:
16281730
"""
16291731
Find a :class:`~.Referable` in this Namespace by its id_short or by its id_short path.
16301732
The id_short path may contain :class:`~basyx.aas.model.submodel.SubmodelElementList` indices.
16311733
1632-
:param id_short: id_short or id_short path as any :class:`Iterable`
1734+
:param id_short_path: id_short or id_short path as a str or any :class:`Iterable`
16331735
:returns: :class:`~.Referable`
16341736
:raises TypeError: If one of the intermediate objects on the path is not a
16351737
:class:`~.UniqueIdShortNamespace`
@@ -1638,10 +1740,10 @@ def get_referable(self, id_short: Union[NameType, Iterable[NameType]]) -> Refera
16381740
:raises KeyError: If no such :class:`~.Referable` can be found
16391741
"""
16401742
from .submodel import SubmodelElementList
1641-
if isinstance(id_short, NameType):
1642-
id_short = [id_short]
1743+
if isinstance(id_short_path, (str, NameType)):
1744+
id_short_path = Referable.parse_id_short_path(id_short_path)
16431745
item: Union[UniqueIdShortNamespace, Referable] = self
1644-
for id_ in id_short:
1746+
for id_ in id_short_path:
16451747
# This is redundant on first iteration, but it's a negligible overhead.
16461748
# Also, ModelReference.resolve() relies on this check.
16471749
if not isinstance(item, UniqueIdShortNamespace):

sdk/test/examples/test_helpers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ def test_submodel_element_collection_checker(self):
227227
self.assertEqual("FAIL: Attribute value of SubmodelElementCollection[Collection] must contain 2 "
228228
"SubmodelElements (count=1)",
229229
repr(next(checker_iterator)))
230-
self.assertEqual("FAIL: Submodel Element Property[Collection / Prop1] must exist ()",
230+
self.assertEqual("FAIL: Submodel Element Property[Collection.Prop1] must exist ()",
231231
repr(next(checker_iterator)))
232232

233233
collection.add_referable(property)
@@ -291,7 +291,7 @@ def test_annotated_relationship_element(self):
291291
self.assertEqual("FAIL: Attribute annotation of AnnotatedRelationshipElement[test] must contain 1 DataElements "
292292
"(count=0)",
293293
repr(next(checker_iterator)))
294-
self.assertEqual("FAIL: Annotation Property[test / ExampleAnnotatedProperty] must exist ()",
294+
self.assertEqual("FAIL: Annotation Property[test.ExampleAnnotatedProperty] must exist ()",
295295
repr(next(checker_iterator)))
296296

297297
def test_submodel_checker(self):

0 commit comments

Comments
 (0)