Skip to content

Commit 39e869b

Browse files
committed
adapter.json: Add opt-in deterministic JSON array order
JSON arrays that originate from unordered Python sets (the top-level object lists and set-valued attributes like `submodel` and `isCaseOf`) are serialized in non-deterministic order, because set iteration order varies across runs due to hash randomization. This makes serialized output non-reproducible, which complicates diffing and caching. A new opt-in `sort_arrays` option restores a stable, deterministic order without changing the default behavior.
1 parent ea3ad3c commit 39e869b

2 files changed

Lines changed: 138 additions & 14 deletions

File tree

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

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,42 @@ class AASToJsonEncoder(json.JSONEncoder):
5555
:cvar stripped: If True, the JSON objects will be serialized in a stripped manner, excluding some attributes.
5656
Defaults to ``False``.
5757
See https://git.rwth-aachen.de/acplt/pyi40aas/-/issues/91
58+
:cvar sort_arrays: If True, JSON arrays that originate from unordered Python sets are sorted by a stable key, so
59+
that the serialized output is deterministic across runs (Python sets have non-deterministic
60+
iteration order due to hash randomization). Defaults to ``False`` to preserve backward
61+
compatibility. Enabled via the ``sort_arrays`` parameter of :func:`write_aas_json_file` or
62+
:func:`object_store_to_json`.
5863
"""
5964
stripped = False
65+
sort_arrays = False
66+
67+
@classmethod
68+
def _maybe_sort(cls, items: Iterable, key: Callable) -> list:
69+
"""
70+
Return ``items`` as a list, sorted by ``key`` only if :attr:`sort_arrays` is enabled.
71+
72+
This is used for JSON arrays that originate from unordered Python sets. Sorting makes the serialized
73+
output deterministic across runs; it is opt-in so that the default behavior remains unchanged.
74+
75+
:param items: The iterable (typically a set) to convert to a list.
76+
:param key: Sort key callable, applied to each item when sorting is enabled.
77+
:return: A list of the items, sorted iff :attr:`sort_arrays` is True.
78+
"""
79+
if cls.sort_arrays:
80+
return sorted(items, key=key)
81+
return list(items)
82+
83+
@staticmethod
84+
def _reference_sort_key(ref: model.Reference) -> list:
85+
"""
86+
Stable sort key for a :class:`~basyx.aas.model.base.Reference`, derived from its structural ``key`` chain
87+
rather than from ``str()``/``repr()``. This keeps the serialized order of set-valued reference attributes
88+
(e.g. ``submodels``, ``isCaseOf``) independent of any future changes to the ``__repr__``/``__str__`` methods.
89+
90+
:param ref: The reference to derive a sort key for.
91+
:return: A list of ``(key type name, key value)`` tuples, comparable across references.
92+
"""
93+
return [(k.type.name, k.value) for k in ref.key]
6094

6195
@classmethod
6296
def _get_aas_class_serializers(cls) -> Dict[Type, Callable]:
@@ -335,7 +369,7 @@ def _concept_description_to_json(cls, obj: model.ConceptDescription) -> Dict[str
335369
"""
336370
data = cls._abstract_classes_to_json(obj)
337371
if obj.is_case_of:
338-
data['isCaseOf'] = list(obj.is_case_of)
372+
data['isCaseOf'] = cls._maybe_sort(obj.is_case_of, key=cls._reference_sort_key)
339373
return data
340374

341375
@classmethod
@@ -390,7 +424,7 @@ def _asset_administration_shell_to_json(cls, obj: model.AssetAdministrationShell
390424
if obj.asset_information:
391425
data["assetInformation"] = obj.asset_information
392426
if not cls.stripped and obj.submodel:
393-
data["submodels"] = list(obj.submodel)
427+
data["submodels"] = cls._maybe_sort(obj.submodel, key=cls._reference_sort_key)
394428
return data
395429

396430
# #################################################################
@@ -687,22 +721,29 @@ class StrippedAASToJsonEncoder(AASToJsonEncoder):
687721
stripped = True
688722

689723

690-
def _select_encoder(stripped: bool, encoder: Optional[Type[AASToJsonEncoder]] = None) -> Type[AASToJsonEncoder]:
724+
def _select_encoder(stripped: bool, encoder: Optional[Type[AASToJsonEncoder]] = None,
725+
sort_arrays: bool = False) -> Type[AASToJsonEncoder]:
691726
"""
692727
Returns the correct encoder based on the stripped parameter. If an encoder class is given, stripped is ignored.
693728
694729
:param stripped: If true, an encoder for parsing stripped JSON objects is selected. Ignored if an encoder class is
695730
specified.
696731
:param encoder: Is returned, if specified.
732+
:param sort_arrays: If true, a subclass of the selected encoder with :attr:`~.AASToJsonEncoder.sort_arrays`
733+
enabled is returned, so that arrays originating from unordered sets are serialized in a
734+
deterministic order. This wrapping is applied even when a custom ``encoder`` is given.
697735
:return: A AASToJsonEncoder (sub)class.
698736
"""
699-
if encoder is not None:
700-
return encoder
701-
return AASToJsonEncoder if not stripped else StrippedAASToJsonEncoder
737+
encoder_ = encoder if encoder is not None \
738+
else (StrippedAASToJsonEncoder if stripped else AASToJsonEncoder)
739+
if sort_arrays and not encoder_.sort_arrays:
740+
return type("Sorting" + encoder_.__name__, (encoder_,), {"sort_arrays": True})
741+
return encoder_
702742

703743

704744
def _create_dict(data: model.AbstractObjectStore,
705-
keys_to_types: Iterable[Tuple[str, Type]] = JSON_AAS_TOP_LEVEL_KEYS_TO_TYPES) \
745+
keys_to_types: Iterable[Tuple[str, Type]] = JSON_AAS_TOP_LEVEL_KEYS_TO_TYPES,
746+
sort: bool = False) \
706747
-> Dict[str, List[model.Identifiable]]:
707748
"""
708749
Categorizes objects from an AbstractObjectStore into a dictionary based on their types.
@@ -715,6 +756,10 @@ def _create_dict(data: model.AbstractObjectStore,
715756
:param keys_to_types: An iterable of tuples where each tuple contains:
716757
- A string key representing the category name.
717758
- A type to match objects against.
759+
:param sort: If True, each output list is sorted by ``str(obj.id)``, so that the top-level arrays
760+
("assetAdministrationShells", "submodels", "conceptDescriptions") have deterministic order
761+
across runs (an AbstractObjectStore is backed by an unordered Python set). Defaults to False
762+
to preserve backward compatibility.
718763
:return: A dictionary where keys are category names and values are lists of objects of the corresponding types.
719764
"""
720765
objects: Dict[str, List[model.Identifiable]] = {}
@@ -728,11 +773,14 @@ def _create_dict(data: model.AbstractObjectStore,
728773
objects.setdefault(name, [])
729774
objects[name].append(obj)
730775
break # Exit the inner loop once a match is found
776+
if sort:
777+
for object_list in objects.values():
778+
object_list.sort(key=lambda o: str(o.id))
731779
return objects
732780

733781

734782
def object_store_to_json(data: model.AbstractObjectStore, stripped: bool = False,
735-
encoder: Optional[Type[AASToJsonEncoder]] = None, **kwargs) -> str:
783+
encoder: Optional[Type[AASToJsonEncoder]] = None, sort_arrays: bool = False, **kwargs) -> str:
736784
"""
737785
Create a json serialization of a set of AAS objects according to 'Details of the Asset Administration Shell',
738786
chapter 5.5
@@ -743,11 +791,15 @@ def object_store_to_json(data: model.AbstractObjectStore, stripped: bool = False
743791
See https://git.rwth-aachen.de/acplt/pyi40aas/-/issues/91
744792
This parameter is ignored if an encoder class is specified.
745793
:param encoder: The encoder class used to encode the JSON objects
794+
:param sort_arrays: If True, JSON arrays that originate from unordered Python sets (the top-level object lists as
795+
well as set-valued attributes like ``submodel`` and ``isCaseOf``) are sorted by a stable key,
796+
so that the serialized output is deterministic across runs. Defaults to False to preserve
797+
backward compatibility. Independent of the ``sort_keys`` argument passed to :func:`json.dumps`.
746798
:param kwargs: Additional keyword arguments to be passed to :func:`json.dumps`
747799
"""
748-
encoder_ = _select_encoder(stripped, encoder)
800+
encoder_ = _select_encoder(stripped, encoder, sort_arrays=sort_arrays)
749801
# serialize object to json
750-
return json.dumps(_create_dict(data), cls=encoder_, **kwargs)
802+
return json.dumps(_create_dict(data, sort=sort_arrays), cls=encoder_, **kwargs)
751803

752804

753805
class _DetachingTextIOWrapper(io.TextIOWrapper):
@@ -759,7 +811,7 @@ def __exit__(self, exc_type, exc_val, exc_tb):
759811

760812

761813
def write_aas_json_file(file: _generic.PathOrIO, data: model.AbstractObjectStore, stripped: bool = False,
762-
encoder: Optional[Type[AASToJsonEncoder]] = None, **kwargs) -> None:
814+
encoder: Optional[Type[AASToJsonEncoder]] = None, sort_arrays: bool = False, **kwargs) -> None:
763815
"""
764816
Write a set of AAS objects to an Asset Administration Shell JSON file according to 'Details of the Asset
765817
Administration Shell', chapter 5.5
@@ -771,9 +823,13 @@ def write_aas_json_file(file: _generic.PathOrIO, data: model.AbstractObjectStore
771823
See https://git.rwth-aachen.de/acplt/pyi40aas/-/issues/91
772824
This parameter is ignored if an encoder class is specified.
773825
:param encoder: The encoder class used to encode the JSON objects
826+
:param sort_arrays: If True, JSON arrays that originate from unordered Python sets (the top-level object lists as
827+
well as set-valued attributes like ``submodel`` and ``isCaseOf``) are sorted by a stable key,
828+
so that the serialized output is deterministic across runs. Defaults to False to preserve
829+
backward compatibility. Independent of the ``sort_keys`` argument passed to :func:`json.dump`.
774830
:param kwargs: Additional keyword arguments to be passed to `json.dump()`
775831
"""
776-
encoder_ = _select_encoder(stripped, encoder)
832+
encoder_ = _select_encoder(stripped, encoder, sort_arrays=sort_arrays)
777833

778834
# json.dump() only accepts TextIO
779835
cm: ContextManager[TextIO]
@@ -791,4 +847,4 @@ def write_aas_json_file(file: _generic.PathOrIO, data: model.AbstractObjectStore
791847

792848
# serialize object to json
793849
with cm as fp:
794-
json.dump(_create_dict(data), fp, cls=encoder_, **kwargs)
850+
json.dump(_create_dict(data, sort=sort_arrays), fp, cls=encoder_, **kwargs)

sdk/test/adapter/json/test_json_serialization.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
import json
1111

1212
from basyx.aas import model
13-
from basyx.aas.adapter.json import AASToJsonEncoder, StrippedAASToJsonEncoder, write_aas_json_file
13+
from basyx.aas.adapter.json import AASToJsonEncoder, StrippedAASToJsonEncoder, write_aas_json_file, \
14+
object_store_to_json
1415
from jsonschema import validate # type: ignore
1516
from typing import Set, Union
1617

@@ -232,3 +233,70 @@ def test_stripped_asset_administration_shell(self) -> None:
232233
)
233234

234235
self._checkNormalAndStripped({"submodels"}, aas)
236+
237+
238+
class JsonSerializationDeterministicOrderTest(unittest.TestCase):
239+
"""
240+
Tests for the opt-in ``sort_arrays`` serialization option, which makes JSON arrays originating from unordered
241+
Python sets deterministic. The assertions check for the *sorted* result, which is fully deterministic and does
242+
not rely on (non-reproducible) set iteration order.
243+
"""
244+
@staticmethod
245+
def _submodel_store(ids) -> "model.DictIdentifiableStore[model.Identifiable]":
246+
store: model.DictIdentifiableStore[model.Identifiable] = model.DictIdentifiableStore()
247+
for id_ in ids:
248+
store.add(model.Submodel(id_))
249+
return store
250+
251+
def test_top_level_arrays_sorted(self) -> None:
252+
# the top-level object lists are backed by an unordered AbstractObjectStore
253+
ids = ["http://example.org/sm_c", "http://example.org/sm_a", "http://example.org/sm_b"]
254+
data = json.loads(object_store_to_json(self._submodel_store(ids), sort_arrays=True))
255+
serialized_ids = [sm["id"] for sm in data["submodels"]]
256+
self.assertEqual(serialized_ids, sorted(ids))
257+
258+
def test_order_independent_of_insertion_order(self) -> None:
259+
ids = ["http://example.org/sm_c", "http://example.org/sm_a", "http://example.org/sm_b"]
260+
out1 = object_store_to_json(self._submodel_store(ids), sort_arrays=True)
261+
out2 = object_store_to_json(self._submodel_store(list(reversed(ids))), sort_arrays=True)
262+
self.assertEqual(out1, out2)
263+
264+
def test_set_valued_attribute_sorted(self) -> None:
265+
# the submodel references of an AssetAdministrationShell are stored in an unordered set
266+
refs = {model.ModelReference((model.Key(model.KeyTypes.SUBMODEL, v),), model.Submodel)
267+
for v in ("SM_C", "SM_A", "SM_B")}
268+
aas = model.AssetAdministrationShell(
269+
model.AssetInformation(global_asset_id="http://example.org/asset"),
270+
"http://example.org/aas", submodel=refs)
271+
store: model.DictIdentifiableStore[model.Identifiable] = model.DictIdentifiableStore()
272+
store.add(aas)
273+
data = json.loads(object_store_to_json(store, sort_arrays=True))
274+
values = [ref["keys"][0]["value"] for ref in data["assetAdministrationShells"][0]["submodels"]]
275+
self.assertEqual(values, ["SM_A", "SM_B", "SM_C"])
276+
277+
def test_is_case_of_sorted(self) -> None:
278+
# the isCaseOf references of a ConceptDescription are stored in an unordered set
279+
refs: Set[model.Reference] = {
280+
model.ExternalReference((model.Key(model.KeyTypes.GLOBAL_REFERENCE, v),))
281+
for v in ("http://example.org/c", "http://example.org/a", "http://example.org/b")}
282+
cd = model.ConceptDescription("http://example.org/cd", is_case_of=refs)
283+
store: model.DictIdentifiableStore[model.Identifiable] = model.DictIdentifiableStore()
284+
store.add(cd)
285+
data = json.loads(object_store_to_json(store, sort_arrays=True))
286+
values = [ref["keys"][0]["value"] for ref in data["conceptDescriptions"][0]["isCaseOf"]]
287+
self.assertEqual(values, ["http://example.org/a", "http://example.org/b", "http://example.org/c"])
288+
289+
def test_sort_arrays_independent_of_sort_keys(self) -> None:
290+
# sort_keys only orders dict keys; it must not implicitly sort arrays. Passing it must not raise and must
291+
# still produce schema-shaped output.
292+
ids = ["http://example.org/sm_c", "http://example.org/sm_a"]
293+
data = json.loads(object_store_to_json(self._submodel_store(ids), sort_keys=True))
294+
self.assertEqual({sm["id"] for sm in data["submodels"]}, set(ids))
295+
296+
def test_write_aas_json_file_sort_arrays(self) -> None:
297+
ids = ["http://example.org/sm_c", "http://example.org/sm_a", "http://example.org/sm_b"]
298+
file = io.StringIO()
299+
write_aas_json_file(file=file, data=self._submodel_store(ids), sort_arrays=True)
300+
file.seek(0)
301+
data = json.load(file)
302+
self.assertEqual([sm["id"] for sm in data["submodels"]], sorted(ids))

0 commit comments

Comments
 (0)