Skip to content

Commit 702d74e

Browse files
authored
sdk: Fix walk_submodel() skipping Entity and Operation children (#465)
Previously, the `sdk`'s `util.traversal.walk_submodel()` method skipped `Entity` and `Operation` children, both `SubmodelElement`s that can contain further children `SubmodelElement`s. This fixes this bug and adds additional unittests to check for successful traversal of all kinds of `SubmodelElement`s. Fixes #423
1 parent d99c15a commit 702d74e

2 files changed

Lines changed: 149 additions & 10 deletions

File tree

sdk/basyx/aas/util/traversal.py

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2025 the Eclipse BaSyx Authors
1+
# Copyright (c) 2026 the Eclipse BaSyx Authors
22
#
33
# This program and the accompanying materials are made available under the terms of the MIT License, available in
44
# the LICENSE file of this project.
@@ -8,26 +8,51 @@
88
A module with helper functions for traversing AAS object structures.
99
"""
1010

11-
from typing import Union, Iterator
11+
from typing import Iterator
1212

1313
from .. import model
1414

1515

16-
def walk_submodel(collection: Union[model.Submodel, model.SubmodelElementCollection, model.SubmodelElementList]) \
17-
-> Iterator[model.SubmodelElement]:
16+
def walk_submodel_element(element: model.SubmodelElement) -> Iterator[model.SubmodelElement]:
17+
"""
18+
Traverse all :class:`SubmodelElements <basyx.aas.model.submodel.SubmodelElement>` contained within the given
19+
element recursively, i.e. the children of:
20+
:class:`~basyx.aas.model.submodel.SubmodelElementCollection`,
21+
:class:`~basyx.aas.model.submodel.SubmodelElementList`,
22+
:class:`~basyx.aas.model.submodel.Entity` (via ``statement``) or
23+
:class:`~basyx.aas.model.submodel.Operation` (via ``input_variable``, ``output_variable``, ``in_output_variable``).
24+
25+
The given element itself is not yielded. This is a generator function, yielding all the
26+
:class:`SubmodelElements <basyx.aas.model.submodel.SubmodelElement>`.
27+
No :class:`SubmodelElements <basyx.aas.model.submodel.SubmodelElement>` should be added, removed or
28+
moved while iterating, as this could result in undefined behaviour.
29+
"""
30+
if isinstance(element, (model.SubmodelElementCollection, model.SubmodelElementList)):
31+
for sub_element in element.value:
32+
yield from walk_submodel_element(sub_element)
33+
yield sub_element
34+
elif isinstance(element, model.Operation):
35+
for var_list in (element.input_variable, element.output_variable, element.in_output_variable):
36+
for sub_element in var_list:
37+
yield from walk_submodel_element(sub_element)
38+
yield sub_element
39+
elif isinstance(element, model.Entity):
40+
for sub_element in element.statement:
41+
yield from walk_submodel_element(sub_element)
42+
yield sub_element
43+
44+
45+
def walk_submodel(submodel: model.Submodel) -> Iterator[model.SubmodelElement]:
1846
"""
1947
Traverse the :class:`SubmodelElements <basyx.aas.model.submodel.SubmodelElement>` in a
20-
:class:`~basyx.aas.model.submodel.Submodel`, :class:`~basyx.aas.model.submodel.SubmodelElementCollection` or a
21-
:class:`~basyx.aas.model.submodel.SubmodelElementList` recursively in post-order tree-traversal.
48+
:class:`~basyx.aas.model.submodel.Submodel` recursively.
2249
2350
This is a generator function, yielding all the :class:`SubmodelElements <basyx.aas.model.submodel.SubmodelElement>`.
2451
No :class:`SubmodelElements <basyx.aas.model.submodel.SubmodelElement>` should be added, removed or
2552
moved while iterating, as this could result in undefined behaviour.
2653
"""
27-
elements = collection.submodel_element if isinstance(collection, model.Submodel) else collection.value
28-
for element in elements:
29-
if isinstance(element, (model.SubmodelElementCollection, model.SubmodelElementList)):
30-
yield from walk_submodel(element)
54+
for element in submodel.submodel_element:
55+
yield from walk_submodel_element(element)
3156
yield element
3257

3358

sdk/test/util/test_traversal.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Copyright (c) 2026 the Eclipse BaSyx Authors
2+
#
3+
# This program and the accompanying materials are made available under the terms of the MIT License, available in
4+
# the LICENSE file of this project.
5+
#
6+
# SPDX-License-Identifier: MIT
7+
8+
import unittest
9+
10+
from basyx.aas import model
11+
from basyx.aas.util.traversal import walk_submodel, walk_submodel_element
12+
13+
14+
class TestWalkSubmodel(unittest.TestCase):
15+
def _submodel(self, *elements: model.SubmodelElement) -> model.Submodel:
16+
return model.Submodel("test-submodel", submodel_element=list(elements))
17+
18+
def test_flat_submodel(self):
19+
prop = model.Property("prop", model.datatypes.String)
20+
sm = self._submodel(prop)
21+
result = list(walk_submodel(sm))
22+
self.assertCountEqual([prop], result)
23+
24+
def test_collection_traversal(self):
25+
child1 = model.Property("child1", model.datatypes.String)
26+
child2 = model.Property("child2", model.datatypes.String)
27+
coll = model.SubmodelElementCollection("coll", value=[child1, child2])
28+
sm = self._submodel(coll)
29+
result = list(walk_submodel(sm))
30+
self.assertCountEqual([child1, child2, coll], result)
31+
32+
def test_list_traversal(self):
33+
child = model.Property(None, model.datatypes.String)
34+
sml = model.SubmodelElementList("sml", type_value_list_element=model.Property,
35+
value_type_list_element=model.datatypes.String, value=[child])
36+
sm = self._submodel(sml)
37+
result = list(walk_submodel(sm))
38+
self.assertCountEqual([child, sml], result)
39+
40+
def test_entity_statement_traversal(self):
41+
stmt = model.Property("stmt", model.datatypes.String)
42+
entity = model.Entity("entity", model.EntityType.CO_MANAGED_ENTITY, statement=[stmt])
43+
sm = self._submodel(entity)
44+
result = list(walk_submodel(sm))
45+
self.assertCountEqual([stmt, entity], result)
46+
47+
def test_entity_empty_statement(self):
48+
entity = model.Entity("entity", model.EntityType.CO_MANAGED_ENTITY)
49+
sm = self._submodel(entity)
50+
result = list(walk_submodel(sm))
51+
self.assertCountEqual([entity], result)
52+
53+
def test_operation_variables_traversal(self):
54+
in_var = model.Property("in_var", model.datatypes.String)
55+
out_var = model.Property("out_var", model.datatypes.String)
56+
inout_var = model.Property("inout_var", model.datatypes.String)
57+
op = model.Operation("op", input_variable=[in_var], output_variable=[out_var],
58+
in_output_variable=[inout_var])
59+
sm = self._submodel(op)
60+
result = list(walk_submodel(sm))
61+
self.assertCountEqual([in_var, out_var, inout_var, op], result)
62+
63+
def test_operation_empty_variables(self):
64+
op = model.Operation("op")
65+
sm = self._submodel(op)
66+
result = list(walk_submodel(sm))
67+
self.assertCountEqual([op], result)
68+
69+
def test_collection_inside_entity_statement(self):
70+
inner = model.Property("inner", model.datatypes.String)
71+
coll = model.SubmodelElementCollection("coll", value=[inner])
72+
entity = model.Entity("entity", model.EntityType.CO_MANAGED_ENTITY, statement=[coll])
73+
sm = self._submodel(entity)
74+
result = list(walk_submodel(sm))
75+
self.assertCountEqual([inner, coll, entity], result)
76+
77+
def test_entity_inside_operation_input_variable(self):
78+
stmt = model.Property("stmt", model.datatypes.String)
79+
entity = model.Entity("entity", model.EntityType.CO_MANAGED_ENTITY, statement=[stmt])
80+
op = model.Operation("op", input_variable=[entity])
81+
sm = self._submodel(op)
82+
result = list(walk_submodel(sm))
83+
self.assertCountEqual([stmt, entity, op], result)
84+
85+
def test_walk_from_collection(self):
86+
prop = model.Property("prop", model.datatypes.String)
87+
entity = model.Entity("entity", model.EntityType.CO_MANAGED_ENTITY, statement=[prop])
88+
coll = model.SubmodelElementCollection("coll", value=[entity])
89+
# walk_submodel_element yields descendants of coll, not coll itself
90+
result = list(walk_submodel_element(coll))
91+
self.assertCountEqual([prop, entity], result)
92+
93+
def test_walk_from_list(self):
94+
op = model.Operation(None)
95+
sml = model.SubmodelElementList("sml", type_value_list_element=model.Operation, value=[op])
96+
# walk_submodel_element yields descendants of sml, not sml itself
97+
result = list(walk_submodel_element(sml))
98+
self.assertCountEqual([op], result)
99+
100+
def test_file_inside_entity_is_found(self):
101+
"""Regression test for issue #423: File inside Entity.statement must be yielded."""
102+
f = model.File("file", content_type="application/pdf", value="/some/file.pdf")
103+
entity = model.Entity("entity", model.EntityType.CO_MANAGED_ENTITY, statement=[f])
104+
sm = self._submodel(entity)
105+
files = [e for e in walk_submodel(sm) if isinstance(e, model.File)]
106+
self.assertIn(f, files)
107+
108+
def test_file_inside_operation_variable_is_found(self):
109+
"""Regression test for issue #423: File inside Operation variable must be yielded."""
110+
f = model.File("file", content_type="application/pdf", value="/some/file.pdf")
111+
op = model.Operation("op", input_variable=[f])
112+
sm = self._submodel(op)
113+
files = [e for e in walk_submodel(sm) if isinstance(e, model.File)]
114+
self.assertIn(f, files)

0 commit comments

Comments
 (0)