Skip to content

Commit cde4696

Browse files
committed
sdk: Fix walk_submodel() skipping Entity and Operation children
Fixes #423. The traversal now recurses into Entity.statement and Operation.input_variable/output_variable/in_output_variable, so File SubmodelElements nested inside these containers are no longer silently dropped when reading AASX files. Adds test_traversal.py with 13 unit tests covering the fix and regression cases.
1 parent 10ff4e4 commit cde4696

2 files changed

Lines changed: 139 additions & 8 deletions

File tree

sdk/basyx/aas/util/traversal.py

Lines changed: 19 additions & 8 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,27 +8,38 @@
88
A module with helper functions for traversing AAS object structures.
99
"""
1010

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

1313
from .. import model
1414

1515

16+
def _walk_submodel_helper(elements: Iterable[model.SubmodelElement]) -> Iterator[model.SubmodelElement]:
17+
for element in elements:
18+
if isinstance(element, (model.SubmodelElementCollection, model.SubmodelElementList)):
19+
yield from _walk_submodel_helper(element.value)
20+
elif isinstance(element, model.Operation):
21+
for var_list in (element.input_variable, element.output_variable, element.in_output_variable):
22+
yield from _walk_submodel_helper(var_list)
23+
elif isinstance(element, model.Entity):
24+
yield from _walk_submodel_helper(element.statement)
25+
yield element
26+
27+
1628
def walk_submodel(collection: Union[model.Submodel, model.SubmodelElementCollection, model.SubmodelElementList]) \
1729
-> Iterator[model.SubmodelElement]:
1830
"""
1931
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.
32+
:class:`~basyx.aas.model.submodel.Submodel`, :class:`~basyx.aas.model.submodel.SubmodelElementCollection`,
33+
:class:`~basyx.aas.model.submodel.SubmodelElementList`, :class:`~basyx.aas.model.submodel.Entity`
34+
(via ``statement``) or :class:`~basyx.aas.model.submodel.Operation` (via ``input_variable``, ``output_variable``,
35+
``in_output_variable``) recursively in post-order tree-traversal.
2236
2337
This is a generator function, yielding all the :class:`SubmodelElements <basyx.aas.model.submodel.SubmodelElement>`.
2438
No :class:`SubmodelElements <basyx.aas.model.submodel.SubmodelElement>` should be added, removed or
2539
moved while iterating, as this could result in undefined behaviour.
2640
"""
2741
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)
31-
yield element
42+
yield from _walk_submodel_helper(elements)
3243

3344

3445
def walk_semantic_ids_recursive(root: model.Referable) -> Iterator[model.Reference]:

sdk/test/util/test_traversal.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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+
from typing import List
10+
11+
from basyx.aas import model
12+
from basyx.aas.util.traversal import walk_submodel
13+
14+
15+
class TestWalkSubmodel(unittest.TestCase):
16+
def _submodel(self, *elements: model.SubmodelElement) -> model.Submodel:
17+
return model.Submodel("test-submodel", submodel_element=list(elements))
18+
19+
def test_flat_submodel(self):
20+
prop = model.Property("prop", model.datatypes.String)
21+
sm = self._submodel(prop)
22+
result = list(walk_submodel(sm))
23+
self.assertEqual([prop], result)
24+
25+
def test_collection_post_order(self):
26+
child1 = model.Property("child1", model.datatypes.String)
27+
child2 = model.Property("child2", model.datatypes.String)
28+
coll = model.SubmodelElementCollection("coll", value=[child1, child2])
29+
sm = self._submodel(coll)
30+
result = list(walk_submodel(sm))
31+
# post-order: children before parent
32+
self.assertEqual([child1, child2, coll], result)
33+
34+
def test_list_post_order(self):
35+
child = model.Property(None, model.datatypes.String)
36+
sml = model.SubmodelElementList("sml", type_value_list_element=model.Property,
37+
value_type_list_element=model.datatypes.String, value=[child])
38+
sm = self._submodel(sml)
39+
result = list(walk_submodel(sm))
40+
self.assertEqual([child, sml], result)
41+
42+
def test_entity_statement_post_order(self):
43+
stmt = model.Property("stmt", model.datatypes.String)
44+
entity = model.Entity("entity", model.EntityType.CO_MANAGED_ENTITY, statement=[stmt])
45+
sm = self._submodel(entity)
46+
result = list(walk_submodel(sm))
47+
# post-order: statement element before Entity
48+
self.assertEqual([stmt, entity], result)
49+
50+
def test_entity_empty_statement(self):
51+
entity = model.Entity("entity", model.EntityType.CO_MANAGED_ENTITY)
52+
sm = self._submodel(entity)
53+
result = list(walk_submodel(sm))
54+
self.assertEqual([entity], result)
55+
56+
def test_operation_variables_post_order(self):
57+
in_var = model.Property("in_var", model.datatypes.String)
58+
out_var = model.Property("out_var", model.datatypes.String)
59+
inout_var = model.Property("inout_var", model.datatypes.String)
60+
op = model.Operation("op", input_variable=[in_var], output_variable=[out_var],
61+
in_output_variable=[inout_var])
62+
sm = self._submodel(op)
63+
result = list(walk_submodel(sm))
64+
# post-order: all variable elements before Operation
65+
self.assertEqual([in_var, out_var, inout_var, op], result)
66+
67+
def test_operation_empty_variables(self):
68+
op = model.Operation("op")
69+
sm = self._submodel(op)
70+
result = list(walk_submodel(sm))
71+
self.assertEqual([op], result)
72+
73+
def test_collection_inside_entity_statement(self):
74+
inner = model.Property("inner", model.datatypes.String)
75+
coll = model.SubmodelElementCollection("coll", value=[inner])
76+
entity = model.Entity("entity", model.EntityType.CO_MANAGED_ENTITY, statement=[coll])
77+
sm = self._submodel(entity)
78+
result = list(walk_submodel(sm))
79+
# post-order: inner → coll → entity
80+
self.assertEqual([inner, coll, entity], result)
81+
82+
def test_entity_inside_operation_input_variable(self):
83+
stmt = model.Property("stmt", model.datatypes.String)
84+
entity = model.Entity("entity", model.EntityType.CO_MANAGED_ENTITY, statement=[stmt])
85+
op = model.Operation("op", input_variable=[entity])
86+
sm = self._submodel(op)
87+
result = list(walk_submodel(sm))
88+
# post-order: stmt → entity → op
89+
self.assertEqual([stmt, entity, op], result)
90+
91+
def test_walk_from_collection(self):
92+
prop = model.Property("prop", model.datatypes.String)
93+
entity = model.Entity("entity", model.EntityType.CO_MANAGED_ENTITY, statement=[prop])
94+
coll = model.SubmodelElementCollection("coll", value=[entity])
95+
# walk_submodel(coll) yields the contents of coll, not coll itself
96+
result = list(walk_submodel(coll))
97+
self.assertEqual([prop, entity], result)
98+
99+
def test_walk_from_list(self):
100+
op = model.Operation(None)
101+
sml = model.SubmodelElementList("sml", type_value_list_element=model.Operation, value=[op])
102+
# walk_submodel(sml) yields the contents of sml, not sml itself
103+
result = list(walk_submodel(sml))
104+
self.assertEqual([op], result)
105+
106+
def test_file_inside_entity_is_found(self):
107+
"""Regression test for issue #423: File inside Entity.statement must be yielded."""
108+
f = model.File("file", content_type="application/pdf", value="/some/file.pdf")
109+
entity = model.Entity("entity", model.EntityType.CO_MANAGED_ENTITY, statement=[f])
110+
sm = self._submodel(entity)
111+
files: List[model.File] = [e for e in walk_submodel(sm) if isinstance(e, model.File)]
112+
self.assertEqual([f], files)
113+
114+
def test_file_inside_operation_variable_is_found(self):
115+
"""Regression test for issue #423: File inside Operation variable must be yielded."""
116+
f = model.File("file", content_type="application/pdf", value="/some/file.pdf")
117+
op = model.Operation("op", input_variable=[f])
118+
sm = self._submodel(op)
119+
files: List[model.File] = [e for e in walk_submodel(sm) if isinstance(e, model.File)]
120+
self.assertEqual([f], files)

0 commit comments

Comments
 (0)