From 855693e0ef90311c497299af3b80ab7fc97edad2 Mon Sep 17 00:00:00 2001 From: Igor Garmaev <56840636+zrgt@users.noreply.github.com> Date: Wed, 13 May 2026 15:05:20 +0200 Subject: [PATCH 01/15] fix: align ServiceSpecificationProfileEnum with IDTA-01002 v3.1.2 spec (#541) - Fix SUBMODEL_READ/SUBMODEL_VALUE names (were swapped: SSP-002=Read, SSP-003=Value) - Rename AAS_REPOSITORY_BULK -> AAS_REPOSITORY_QUERY (SSP-003 is Query, no Bulk exists) - Rename SUBMODEL_REPOSITORY_BULK -> SUBMODEL_REPOSITORY_TEMPLATE (SSP-003 is Template) - Rename CONCEPT_DESCRIPTION_REPOSITORY_READ -> CONCEPT_DESCRIPTION_REPOSITORY_QUERY (SSP-002) - Remove CONCEPT_DESCRIPTION_REPOSITORY_BULK (SSP-003 does not exist in spec) - Add AAS_REGISTRY_QUERY (SSP-004), AAS_REGISTRY_MINIMAL_READ (SSP-005) - Add SUBMODEL_REGISTRY_QUERY (SSP-004) - Add SUBMODEL_REPOSITORY_TEMPLATE_READ (SSP-004), SUBMODEL_REPOSITORY_QUERY (SSP-005) --- server/app/model/service_specification.py | 27 ++++++++++++++++------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/server/app/model/service_specification.py b/server/app/model/service_specification.py index 00b4a5da..5181901a 100644 --- a/server/app/model/service_specification.py +++ b/server/app/model/service_specification.py @@ -5,8 +5,11 @@ class ServiceSpecificationProfileEnum(str, enum.Enum): """ Enumeration of all standardized Service Specification Profiles - from the AAS Part 2 API Specification (IDTA-01002-3-1). + from the AAS Part 2 API Specification (IDTA-01002-3-1-2). Each profile is uniquely identified by its semantic URI. + + Reference: https://industrialdigitaltwin.io/aas-specifications/IDTA-01002/v3.1.2/ + http-rest-api/service-specifications-and-profiles.html """ # --- Asset Administration Shell (AAS) --- @@ -15,8 +18,8 @@ class ServiceSpecificationProfileEnum(str, enum.Enum): # --- Submodel --- SUBMODEL_FULL = "https://admin-shell.io/aas/API/3/1/SubmodelServiceSpecification/SSP-001" - SUBMODEL_VALUE = "https://admin-shell.io/aas/API/3/1/SubmodelServiceSpecification/SSP-002" - SUBMODEL_READ = "https://admin-shell.io/aas/API/3/1/SubmodelServiceSpecification/SSP-003" + SUBMODEL_READ = "https://admin-shell.io/aas/API/3/1/SubmodelServiceSpecification/SSP-002" + SUBMODEL_VALUE = "https://admin-shell.io/aas/API/3/1/SubmodelServiceSpecification/SSP-003" # --- AASX File Server --- AASX_FILESERVER_FULL = "https://admin-shell.io/aas/API/3/1/AasxFileServerServiceSpecification/SSP-001" @@ -28,32 +31,40 @@ class ServiceSpecificationProfileEnum(str, enum.Enum): "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellRegistryServiceSpecification/SSP-002" AAS_REGISTRY_BULK = \ "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellRegistryServiceSpecification/SSP-003" + AAS_REGISTRY_QUERY = \ + "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellRegistryServiceSpecification/SSP-004" + AAS_REGISTRY_MINIMAL_READ = \ + "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellRegistryServiceSpecification/SSP-005" # --- Submodel Registry --- SUBMODEL_REGISTRY_FULL = "https://admin-shell.io/aas/API/3/1/SubmodelRegistryServiceSpecification/SSP-001" SUBMODEL_REGISTRY_READ = "https://admin-shell.io/aas/API/3/1/SubmodelRegistryServiceSpecification/SSP-002" SUBMODEL_REGISTRY_BULK = "https://admin-shell.io/aas/API/3/1/SubmodelRegistryServiceSpecification/SSP-003" + SUBMODEL_REGISTRY_QUERY = "https://admin-shell.io/aas/API/3/1/SubmodelRegistryServiceSpecification/SSP-004" # --- AAS Repository --- AAS_REPOSITORY_FULL = \ "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellRepositoryServiceSpecification/SSP-001" AAS_REPOSITORY_READ = \ "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellRepositoryServiceSpecification/SSP-002" - AAS_REPOSITORY_BULK = \ + AAS_REPOSITORY_QUERY = \ "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellRepositoryServiceSpecification/SSP-003" # --- Submodel Repository --- SUBMODEL_REPOSITORY_FULL = "https://admin-shell.io/aas/API/3/1/SubmodelRepositoryServiceSpecification/SSP-001" SUBMODEL_REPOSITORY_READ = "https://admin-shell.io/aas/API/3/1/SubmodelRepositoryServiceSpecification/SSP-002" - SUBMODEL_REPOSITORY_BULK = "https://admin-shell.io/aas/API/3/1/SubmodelRepositoryServiceSpecification/SSP-003" + SUBMODEL_REPOSITORY_TEMPLATE = \ + "https://admin-shell.io/aas/API/3/1/SubmodelRepositoryServiceSpecification/SSP-003" + SUBMODEL_REPOSITORY_TEMPLATE_READ = \ + "https://admin-shell.io/aas/API/3/1/SubmodelRepositoryServiceSpecification/SSP-004" + SUBMODEL_REPOSITORY_QUERY = \ + "https://admin-shell.io/aas/API/3/1/SubmodelRepositoryServiceSpecification/SSP-005" # --- Concept Description Repository --- CONCEPT_DESCRIPTION_REPOSITORY_FULL = \ "https://admin-shell.io/aas/API/3/1/ConceptDescriptionRepositoryServiceSpecification/SSP-001" - CONCEPT_DESCRIPTION_REPOSITORY_READ = \ + CONCEPT_DESCRIPTION_REPOSITORY_QUERY = \ "https://admin-shell.io/aas/API/3/1/ConceptDescriptionRepositoryServiceSpecification/SSP-002" - CONCEPT_DESCRIPTION_REPOSITORY_BULK = \ - "https://admin-shell.io/aas/API/3/1/ConceptDescriptionRepositoryServiceSpecification/SSP-003" # --- Discovery --- DISCOVERY_FULL = "https://admin-shell.io/aas/API/3/1/DiscoveryServiceSpecification/SSP-001" From cd39f2ea7540b955bedac9d98329028e2ecba4d2 Mon Sep 17 00:00:00 2001 From: Igor Garmaev <56840636+zrgt@users.noreply.github.com> Date: Wed, 13 May 2026 15:15:02 +0200 Subject: [PATCH 02/15] fix: NamespaceSet.pop() removes item from all backends (#514) pop() only removed item from first backend via popitem(), leaving stale entries in remaining backends and causing false AASConstraintViolation on subsequent add() for same semantic_id. Fixes #496 --- sdk/basyx/aas/model/base.py | 4 +++- sdk/test/model/test_base.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/sdk/basyx/aas/model/base.py b/sdk/basyx/aas/model/base.py index 6c6eb25e..718c0d63 100644 --- a/sdk/basyx/aas/model/base.py +++ b/sdk/basyx/aas/model/base.py @@ -2077,8 +2077,10 @@ def discard(self, x: _NSO) -> None: def pop(self) -> _NSO: _, value = next(iter(self._backend.values()))[0].popitem() + for key_attr_name, (backend_dict, case_sensitive) in self._backend.items(): + key_attr_value = self._get_attribute(value, key_attr_name, case_sensitive) + backend_dict.pop(key_attr_value, None) self._execute_item_del_hook(value) - value.parent = None return value def clear(self) -> None: diff --git a/sdk/test/model/test_base.py b/sdk/test/model/test_base.py index c5b0429d..3a74a774 100644 --- a/sdk/test/model/test_base.py +++ b/sdk/test/model/test_base.py @@ -338,6 +338,18 @@ def setUp(self): self.namespace = self._namespace_class() self.namespace3 = self._namespace_class_qualifier() + def test_namespaceset_pop_removes_from_all_backends(self) -> None: + # set1 has two backends: id_short and semantic_id + self.namespace.set1.add(self.prop1) + popped = self.namespace.set1.pop() + self.assertIs(self.prop1, popped) + self.assertEqual(0, len(self.namespace.set1)) + # After pop, adding a new item with the same semantic_id must NOT raise AASConstraintViolation — + # it would if the popped item's semantic_id entry were still in the backend + new_prop = model.Property("NewProp", model.datatypes.Int, semantic_id=self.propSemanticID) + self.namespace.set1.add(new_prop) + self.assertEqual(1, len(self.namespace.set1)) + def test_NamespaceSet(self) -> None: self.namespace.set1.add(self.prop1) self.assertEqual(1, len(self.namespace.set1)) From 34a6d6f68d7851601ca8943ee4a2c222022b8a94 Mon Sep 17 00:00:00 2001 From: Igor Garmaev <56840636+zrgt@users.noreply.github.com> Date: Thu, 14 May 2026 13:18:14 +0200 Subject: [PATCH 03/15] Fix load_directory silently dropping all descriptors (#545) Previously `load_directory()` called `read_server_aas_json_file_into()`, which internally only adds items where `isinstance(item, model.Identifiable)`. `AssetAdministrationShellDescriptor` and `SubmodelDescriptor` are not `Identifiable`, so all descriptors were silently skipped. The registry always started empty. This parses descriptor JSON directly with `ServerAASFromJsonDecoder` and add items to `DictDescriptorStore`. Fixes #544 --- server/app/adapter/jsonization.py | 27 +++---------------- server/app/model/provider.py | 44 +++++++++++++++++-------------- 2 files changed, 27 insertions(+), 44 deletions(-) diff --git a/server/app/adapter/jsonization.py b/server/app/adapter/jsonization.py index a8ee3471..897590c7 100644 --- a/server/app/adapter/jsonization.py +++ b/server/app/adapter/jsonization.py @@ -1,10 +1,10 @@ import logging -from typing import Callable, Dict, Optional, Set, Type +from typing import Callable, Dict, Type from basyx.aas import model -from basyx.aas.adapter._generic import ASSET_KIND, ASSET_KIND_INVERSE, JSON_AAS_TOP_LEVEL_KEYS_TO_TYPES, PathOrIO +from basyx.aas.adapter._generic import ASSET_KIND, ASSET_KIND_INVERSE, JSON_AAS_TOP_LEVEL_KEYS_TO_TYPES from basyx.aas.adapter.json import AASToJsonEncoder -from basyx.aas.adapter.json.json_deserialization import AASFromJsonDecoder, _get_ts, read_aas_json_file_into +from basyx.aas.adapter.json.json_deserialization import AASFromJsonDecoder, _get_ts import app.model as server_model @@ -207,27 +207,6 @@ class ServerStrictStrippedAASFromJsonDecoder(ServerStrictAASFromJsonDecoder, Ser pass -def read_server_aas_json_file_into( - object_store: model.AbstractObjectStore, - file: PathOrIO, - replace_existing: bool = False, - ignore_existing: bool = False, - failsafe: bool = True, - stripped: bool = False, - decoder: Optional[Type[AASFromJsonDecoder]] = None, -) -> Set[model.Identifier]: - return read_aas_json_file_into( - object_store=object_store, - file=file, - replace_existing=replace_existing, - ignore_existing=ignore_existing, - failsafe=failsafe, - stripped=stripped, - decoder=decoder, - keys_to_types=JSON_SERVER_AAS_TOP_LEVEL_KEYS_TO_TYPES, - ) - - class ServerAASToJsonEncoder(AASToJsonEncoder): @classmethod diff --git a/server/app/model/provider.py b/server/app/model/provider.py index 97067e7d..409570fe 100644 --- a/server/app/model/provider.py +++ b/server/app/model/provider.py @@ -1,10 +1,11 @@ +import json from pathlib import Path from typing import IO, Dict, Iterable, Iterator, Union from basyx.aas import model from basyx.aas.model import provider as sdk_provider -import app.adapter as adapter +from app.adapter import ServerAASFromJsonDecoder from app.model import descriptor PathOrIO = Union[Path, IO] @@ -53,27 +54,30 @@ def __iter__(self) -> Iterator[_DESCRIPTOR_TYPE]: def load_directory(directory: Union[Path, str]) -> DictDescriptorStore: """ - Create a new :class:`~basyx.aas.model.provider.DictIdentifiableStore` and use it to load Asset Administration Shell - and Submodel files in ``AASX``, ``JSON`` and ``XML`` format from a given directory into memory. Additionally, load - all embedded supplementary files into a new :class:`~basyx.aas.adapter.aasx.DictSupplementaryFileContainer`. - - :param directory: :class:`~pathlib.Path` or ``str`` pointing to the directory containing all Asset Administration - Shell and Submodel files to load - :return: Tuple consisting of a :class:`~basyx.aas.model.provider.DictIdentifiableStore` and a - :class:`~basyx.aas.adapter.aasx.DictSupplementaryFileContainer` containing all loaded data - """ - - dict_descriptor_store: DictDescriptorStore = DictDescriptorStore() + Load AAS/Submodel descriptor JSON files from a directory into a :class:`DictDescriptorStore`. + :param directory: Path to the directory containing JSON descriptor files + :return: Populated :class:`DictDescriptorStore` + """ + store = DictDescriptorStore() directory = Path(directory) for file in directory.iterdir(): - if not file.is_file(): + if not file.is_file() or file.suffix.lower() != ".json": continue - - suffix = file.suffix.lower() - if suffix == ".json": - with open(file) as f: - adapter.read_server_aas_json_file_into(dict_descriptor_store, f) - - return dict_descriptor_store + with open(file) as f: + data = json.load(f, cls=ServerAASFromJsonDecoder) + for item in data.get("assetAdministrationShellDescriptors", []): + if isinstance(item, descriptor.AssetAdministrationShellDescriptor): + try: + store.add(item) + except KeyError: + pass + for item in data.get("submodelDescriptors", []): + if isinstance(item, descriptor.SubmodelDescriptor): + try: + store.add(item) + except KeyError: + pass + + return store From ba01ebf21992fcfd42ab1759974b1bd3a953616c Mon Sep 17 00:00:00 2001 From: Igor Garmaev <56840636+zrgt@users.noreply.github.com> Date: Thu, 14 May 2026 13:59:43 +0200 Subject: [PATCH 04/15] Fix LocalFileIdentifiableStore: count and iterate only .json store files (#507) Previously, `LocalFileIdentifiableStore` counted any file in it's storage directory for its `__len__`. Now it only counts `.json` files. Additional unittests ensure that the file ending used in `__len__` fits with the way files are written in the store. Fixes #499 Fixes #503 --- sdk/basyx/aas/backend/local_file.py | 5 +++-- sdk/test/backend/test_local_file.py | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/sdk/basyx/aas/backend/local_file.py b/sdk/basyx/aas/backend/local_file.py index 4008497a..72d5605a 100644 --- a/sdk/basyx/aas/backend/local_file.py +++ b/sdk/basyx/aas/backend/local_file.py @@ -150,7 +150,7 @@ def __len__(self) -> int: :return: The number of objects (determined from the number of documents) """ logger.debug("Fetching number of documents from database ...") - return len(os.listdir(self.directory_path)) + return sum(1 for f in os.listdir(self.directory_path) if f.lower().endswith(".json")) def __iter__(self) -> Iterator[model.Identifiable]: """ @@ -161,7 +161,8 @@ def __iter__(self) -> Iterator[model.Identifiable]: """ logger.debug("Iterating over objects in database ...") for name in os.listdir(self.directory_path): - yield self.get_identifiable_by_hash(name.rstrip(".json")) + if name.lower().endswith(".json"): + yield self.get_identifiable_by_hash(name[:-5]) @staticmethod def _transform_id(identifier: model.Identifier) -> str: diff --git a/sdk/test/backend/test_local_file.py b/sdk/test/backend/test_local_file.py index adcbfcc7..f1080240 100644 --- a/sdk/test/backend/test_local_file.py +++ b/sdk/test/backend/test_local_file.py @@ -107,6 +107,33 @@ def test_key_errors(self) -> None: self.assertEqual("'No AAS object with id https://example.org/Test_Submodel exists in " "local file database'", str(cm.exception)) + def test_add_and_len_consistent(self) -> None: + # Each add() must increment len() by exactly 1 + example_data = list(create_full_example()) + for i, item in enumerate(example_data): + self.identifiable_store.add(item) + self.assertEqual(i + 1, len(self.identifiable_store)) + + # Stray non-json file must not be counted + stray = os.path.join(store_path, ".DS_Store") + with open(stray, "w") as f: + f.write("stray") + self.assertEqual(len(example_data), len(self.identifiable_store)) + os.remove(stray) + + def test_iter_ignores_non_json_files(self) -> None: + example_data = create_full_example() + for item in example_data: + self.identifiable_store.add(item) + + # Stray files must not crash the iterator or be yielded + stray = os.path.join(store_path, ".DS_Store") + with open(stray, "w") as f: + f.write("stray") + items = list(self.identifiable_store) + self.assertEqual(5, len(items)) + os.remove(stray) + def test_reload_discard(self) -> None: # Load example submodel example_submodel = create_example_submodel() From be31bd0a98f2141632f7bf77a4effa98e721ba23 Mon Sep 17 00:00:00 2001 From: Igor Garmaev <56840636+zrgt@users.noreply.github.com> Date: Thu, 14 May 2026 14:20:20 +0200 Subject: [PATCH 05/15] fix: XML DataSpecificationIEC61360.value independent of value_format (#510) Previously, `DataSpecificationIEC61360.value` was dropped silently, if `DataSpecificationIEC61360.value_format` was `None` in the XML deserialization. There's no constraint (anymore) that enforces this. Therefore, we change the code to deserialize `value` independent of `value_format`. Fixes #501 --- .../aas/adapter/xml/xml_deserialization.py | 2 +- .../adapter/xml/test_xml_deserialization.py | 44 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/sdk/basyx/aas/adapter/xml/xml_deserialization.py b/sdk/basyx/aas/adapter/xml/xml_deserialization.py index b36dddb9..2330b9af 100644 --- a/sdk/basyx/aas/adapter/xml/xml_deserialization.py +++ b/sdk/basyx/aas/adapter/xml/xml_deserialization.py @@ -1158,7 +1158,7 @@ def construct_data_specification_iec61360(cls, element: etree._Element, if value_list is not None: ds_iec.value_list = value_list value = _get_text_or_none(element.find(NS_AAS + "value")) - if value is not None and value_format is not None: + if value is not None: ds_iec.value = value level_type = element.find(NS_AAS + "levelType") if level_type is not None: diff --git a/sdk/test/adapter/xml/test_xml_deserialization.py b/sdk/test/adapter/xml/test_xml_deserialization.py index 2857a9dc..14a5041b 100644 --- a/sdk/test/adapter/xml/test_xml_deserialization.py +++ b/sdk/test/adapter/xml/test_xml_deserialization.py @@ -428,6 +428,50 @@ def test_stripped_asset_administration_shell(self) -> None: self.assertEqual(len(aas.submodel), 0) +class XmlDeserializationDataSpecTest(unittest.TestCase): + def test_data_spec_iec61360_value_without_value_format(self) -> None: + xml = _xml_wrap(f""" + + + http://example.org/test_cd + + + + ExternalReference + + + GlobalReference + https://admin-shell.io/DataSpecificationTemplates/DataSpecificationIec61360/3/0 + + + + + + + + en + Test + + + test_value + + + + + + + """) + object_store = read_aas_xml_file(io.StringIO(xml), failsafe=False) + cd = object_store.get_item("http://example.org/test_cd") + self.assertIsInstance(cd, model.ConceptDescription) + assert isinstance(cd, model.ConceptDescription) + ds_content = list(cd.embedded_data_specifications)[0].data_specification_content + self.assertIsInstance(ds_content, model.DataSpecificationIEC61360) + assert isinstance(ds_content, model.DataSpecificationIEC61360) + self.assertEqual("test_value", ds_content.value) + self.assertIsNone(ds_content.value_format) + + class XmlDeserializationDerivingTest(unittest.TestCase): def test_submodel_constructor_overriding(self) -> None: class EnhancedSubmodel(model.Submodel): From ca78e9a92e4383914065bdf11c7e3841d449ea36 Mon Sep 17 00:00:00 2001 From: Igor Garmaev <56840636+zrgt@users.noreply.github.com> Date: Thu, 14 May 2026 14:31:27 +0200 Subject: [PATCH 06/15] fix: DictSupplementaryFileContainer increment refcount in _assign_unique_name (#513) `DictSupplementaryFileContainer_store_refcount` is designed to track how many `_name_map` entries reference the same content hash, so `delete_file()` can free the underlying bytes only when the last reference is removed. The refcount is never incremented, so files are never freed. Previously, because `_assign_unique_name()` never increments `_store_refcount`, the count stays at 0 after any `add_file()`. Every `delete_file()` decrements to -1 and the equality check `== 0` is never true, so `_store[hash]` and `_store_refcount[hash]` are never cleaned up. Every file ever added leaks indefinitely. This fixes this bug by incrementing `_store_refcount[sha] += 1` inside `_assign_unique_name()` when a new `_name_map` entry is created (the first branch of the `while True` loop). Also decrement it (and skip the increment) inside the second branch when a duplicate name already maps to the same hash. Fixes #495 --- sdk/basyx/aas/adapter/aasx.py | 2 ++ sdk/test/adapter/aasx/test_aasx.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/sdk/basyx/aas/adapter/aasx.py b/sdk/basyx/aas/adapter/aasx.py index 2c7fe0b4..82fe4b76 100644 --- a/sdk/basyx/aas/adapter/aasx.py +++ b/sdk/basyx/aas/adapter/aasx.py @@ -880,6 +880,7 @@ def rename_file(self, old_name: str, new_name: str) -> str: if new_name == old_name: return new_name file_hash, file_content_type = self._name_map[old_name] + self._store_refcount[file_hash] -= 1 del self._name_map[old_name] return self._assign_unique_name(new_name, file_hash, file_content_type) @@ -889,6 +890,7 @@ def _assign_unique_name(self, name: str, sha: bytes, content_type: str) -> str: while True: if new_name not in self._name_map: self._name_map[new_name] = (sha, content_type) + self._store_refcount[sha] += 1 return new_name elif self._name_map[new_name] == (sha, content_type): return new_name diff --git a/sdk/test/adapter/aasx/test_aasx.py b/sdk/test/adapter/aasx/test_aasx.py index 271b992c..e4ab1a1d 100644 --- a/sdk/test/adapter/aasx/test_aasx.py +++ b/sdk/test/adapter/aasx/test_aasx.py @@ -89,6 +89,24 @@ def test_supplementary_file_container(self) -> None: with self.assertRaises(KeyError): container.write_file(duplicate_file, file_content) + def test_supplementary_file_container_refcount(self) -> None: + container = aasx.DictSupplementaryFileContainer() + data = b"test content" + name1 = container.add_file("/file1.bin", io.BytesIO(data), "application/octet-stream") + name2 = container.add_file("/file2.bin", io.BytesIO(data), "application/octet-stream") + content_hash = container.get_sha256(name1) + + # Both names point to same content — backing store must be present + self.assertIn(content_hash, container._store) + + # Deleting one reference must NOT free the backing store + container.delete_file(name1) + self.assertIn(content_hash, container._store) + + # Deleting the last reference must free the backing store + container.delete_file(name2) + self.assertNotIn(content_hash, container._store) + class AASXWriterTest(unittest.TestCase): def test_write_missing_aas_objects(self): From 6dcd3c015af0cd98f4bd627da665214766029f65 Mon Sep 17 00:00:00 2001 From: zrgt Date: Sun, 17 May 2026 19:50:54 +0200 Subject: [PATCH 07/15] feat: add QueryableObjectStore protocol and /query/shells + /query/submodels routes Adds a runtime_checkable Protocol so any object store advertising a query(aasql_body, return_var) method is detected at runtime via isinstance. Repository WSGIApp gains POST /query/shells and POST /query/submodels endpoints that delegate to the store when the protocol is satisfied, returning 501 otherwise. get_description dynamically appends AAS_REPOSITORY_QUERY and SUBMODEL_REPOSITORY_QUERY profiles when the store is queryable. --- server/app/interfaces/base.py | 20 ++++++++++++++- server/app/interfaces/repository.py | 38 +++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/server/app/interfaces/base.py b/server/app/interfaces/base.py index d3231237..865b599b 100644 --- a/server/app/interfaces/base.py +++ b/server/app/interfaces/base.py @@ -10,7 +10,7 @@ import io import itertools import json -from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Tuple, Type, TypeVar, Union +from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Protocol, Tuple, Type, TypeVar, Union, runtime_checkable import werkzeug.exceptions import werkzeug.routing @@ -295,6 +295,24 @@ def http_exception_to_response( return response_type(result, status=exception.code, headers=headers) +@runtime_checkable +class QueryableObjectStore(Protocol): + """Structural protocol for object stores that support AASQL querying. + + Implement ``query(aasql_body, return_var)`` to advertise query support. + No explicit inheritance required — duck-typing via ``isinstance`` works at runtime. + """ + + def query(self, aasql_body: str, return_var: str) -> List[dict]: + """Execute an AASQL query and return matching serialized AAS objects. + + :param aasql_body: raw AASQL JSON string + :param return_var: Cypher return variable name (``"sm"`` or ``"aas"``) + :return: list of serialized AAS/Submodel dicts + """ + ... + + class ObjectStoreWSGIApp(BaseWSGIApp): object_store: AbstractObjectStore diff --git a/server/app/interfaces/repository.py b/server/app/interfaces/repository.py index 0e75eedd..49f7d5ae 100644 --- a/server/app/interfaces/repository.py +++ b/server/app/interfaces/repository.py @@ -24,7 +24,7 @@ from app.interfaces.base import PagingMetadata from app.util.converters import IdentifierToBase64URLConverter, IdShortPathConverter, base64url_decode -from .base import ObjectStoreWSGIApp, APIResponse, is_stripped_request, HTTPApiDecoder, T +from .base import ObjectStoreWSGIApp, APIResponse, is_stripped_request, HTTPApiDecoder, QueryableObjectStore, T from app.model import ServiceSpecificationProfileEnum, ServiceDescription SUPPORTED_PROFILES: ServiceDescription = ServiceDescription([ @@ -32,6 +32,8 @@ ServiceSpecificationProfileEnum.SUBMODEL_REPOSITORY_FULL, ServiceSpecificationProfileEnum.AAS_REPOSITORY_READ, ServiceSpecificationProfileEnum.SUBMODEL_REPOSITORY_READ, + # ServiceSpecificationProfileEnum.AAS_REPOSITORY_QUERY, + # ServiceSpecificationProfileEnum.SUBMODEL_REPOSITORY_QUERY, ]) @@ -49,6 +51,8 @@ def __init__( Submount( base_path, [ + Rule("/query/shells", methods=["POST"], endpoint=self.query_shells), + Rule("/query/submodels", methods=["POST"], endpoint=self.query_submodels), Rule("/serialization", methods=["GET"], endpoint=self.not_implemented), Rule("/description", methods=["GET"], endpoint=self.get_description), Rule("/shells", methods=["GET"], endpoint=self.get_aas_all), @@ -517,12 +521,42 @@ def _get_submodel_submodel_elements_id_short_path(self, url_args: Dict) -> model def _get_concept_description(self, url_args): return self._get_obj_ts(url_args["concept_id"], model.ConceptDescription) + def query_submodels(self, request: Request, url_args: Dict, **_kwargs) -> Response: + if not isinstance(self.object_store, QueryableObjectStore): + raise werkzeug.exceptions.NotImplemented("The current store does not support AASQL queries") + try: + results = self.object_store.query(request.get_data(as_text=True), "sm") + except (json.JSONDecodeError, ValueError) as e: + raise BadRequest(f"Invalid AASQL query: {e}") from e + return Response( + json.dumps({"paging_metadata": {"resultType": "Submodel"}, "result": results}), + content_type="application/json", + ) + + def query_shells(self, request: Request, url_args: Dict, **_kwargs) -> Response: + if not isinstance(self.object_store, QueryableObjectStore): + raise werkzeug.exceptions.NotImplemented("The current store does not support AASQL queries") + try: + results = self.object_store.query(request.get_data(as_text=True), "aas") + except (json.JSONDecodeError, ValueError) as e: + raise BadRequest(f"Invalid AASQL query: {e}") from e + return Response( + json.dumps({"paging_metadata": {"resultType": "AssetAdministrationShell"}, "result": results}), + content_type="application/json", + ) + # ------ all not implemented ROUTES ------- def not_implemented(self, request: Request, url_args: Dict, **_kwargs) -> Response: raise werkzeug.exceptions.NotImplemented("This route is not implemented!") def get_description(self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs) -> Response: - return response_t(SUPPORTED_PROFILES.to_dict()) + profiles = SUPPORTED_PROFILES.to_dict() + if isinstance(self.object_store, QueryableObjectStore): + profiles["profiles"].extend([ + ServiceSpecificationProfileEnum.AAS_REPOSITORY_QUERY.value, + ServiceSpecificationProfileEnum.SUBMODEL_REPOSITORY_QUERY.value, + ]) + return response_t(profiles) # ------ AAS REPO ROUTES ------- def get_aas_all(self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs) -> Response: From 1f0f7078729a4ee10cdc0b37f911afd54430c45c Mon Sep 17 00:00:00 2001 From: zrgt Date: Sun, 17 May 2026 19:51:02 +0200 Subject: [PATCH 08/15] refactor: consolidate load_directory descriptor iteration via _DESCRIPTOR_KEY_TO_CLS Replaces two separate for-loops (one per descriptor type) with a single loop over a module-level tuple. Moves the ServerAASFromJsonDecoder import inside load_directory to break a circular-import risk at module load time. --- server/app/model/provider.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/server/app/model/provider.py b/server/app/model/provider.py index 409570fe..3f529118 100644 --- a/server/app/model/provider.py +++ b/server/app/model/provider.py @@ -5,7 +5,6 @@ from basyx.aas import model from basyx.aas.model import provider as sdk_provider -from app.adapter import ServerAASFromJsonDecoder from app.model import descriptor PathOrIO = Union[Path, IO] @@ -52,6 +51,12 @@ def __iter__(self) -> Iterator[_DESCRIPTOR_TYPE]: return iter(self._backend.values()) +_DESCRIPTOR_KEY_TO_CLS = ( + ("assetAdministrationShellDescriptors", descriptor.AssetAdministrationShellDescriptor), + ("submodelDescriptors", descriptor.SubmodelDescriptor), +) + + def load_directory(directory: Union[Path, str]) -> DictDescriptorStore: """ Load AAS/Submodel descriptor JSON files from a directory into a :class:`DictDescriptorStore`. @@ -59,6 +64,8 @@ def load_directory(directory: Union[Path, str]) -> DictDescriptorStore: :param directory: Path to the directory containing JSON descriptor files :return: Populated :class:`DictDescriptorStore` """ + from app.adapter import ServerAASFromJsonDecoder + store = DictDescriptorStore() directory = Path(directory) @@ -67,17 +74,12 @@ def load_directory(directory: Union[Path, str]) -> DictDescriptorStore: continue with open(file) as f: data = json.load(f, cls=ServerAASFromJsonDecoder) - for item in data.get("assetAdministrationShellDescriptors", []): - if isinstance(item, descriptor.AssetAdministrationShellDescriptor): - try: - store.add(item) - except KeyError: - pass - for item in data.get("submodelDescriptors", []): - if isinstance(item, descriptor.SubmodelDescriptor): - try: - store.add(item) - except KeyError: - pass + for key, cls in _DESCRIPTOR_KEY_TO_CLS: + for item in data.get(key, []): + if isinstance(item, cls): + try: + store.add(item) + except KeyError: + pass return store From 5535d537b0a37e4a459d95275ebc1212fdedb883 Mon Sep 17 00:00:00 2001 From: Igor Garmaev <56840636+zrgt@users.noreply.github.com> Date: Mon, 18 May 2026 14:45:37 +0200 Subject: [PATCH 09/15] Fix GET /shells?assetIds: multiple globalAssetId values silently returned empty results (#512) When 2 or more `globalAssetId` query parameters were sent, a `len(global_asset_ids) <= 1` guard in the filter lambda in `repository.py` evaluated `False` for every shell, causing an empty HTTP 200 response with no error. The guard was likely intended to reject invalid input with a 400 error, not silently discard all results. The guard is replaced with proper input validation that raises `BadRequest` when multiple global asset IDs are provided. Fixes #500 --- server/app/interfaces/repository.py | 5 +---- .../test/interfaces/test_shells_asset_ids.py | 20 +++++++++++++++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/server/app/interfaces/repository.py b/server/app/interfaces/repository.py index 0e75eedd..89ad0d64 100644 --- a/server/app/interfaces/repository.py +++ b/server/app/interfaces/repository.py @@ -470,10 +470,7 @@ def _get_shells( for specific_asset_id in specific_asset_ids ) ) - and ( - len(global_asset_ids) <= 1 - and (not global_asset_ids or shell.asset_information.global_asset_id in global_asset_ids) - ) + and (not global_asset_ids or shell.asset_information.global_asset_id in global_asset_ids) ), aas, ) diff --git a/server/test/interfaces/test_shells_asset_ids.py b/server/test/interfaces/test_shells_asset_ids.py index 8b48d6de..da103807 100644 --- a/server/test/interfaces/test_shells_asset_ids.py +++ b/server/test/interfaces/test_shells_asset_ids.py @@ -16,6 +16,8 @@ from app.interfaces.repository import WSGIApp +BASE_PATH = "/api/v3.1" + def _encode_asset_id(name: str, value: str) -> str: payload = json.dumps({"name": name, "value": value}) @@ -24,10 +26,24 @@ def _encode_asset_id(name: str, value: str) -> str: class ShellsAssetIdsTest(unittest.TestCase): def setUp(self) -> None: - app = WSGIApp(create_full_example(), DictSupplementaryFileContainer()) + self.example_data = create_full_example() + app = WSGIApp(self.example_data, DictSupplementaryFileContainer()) self.client = Client(app) + def test_multiple_global_asset_ids_returns_matching_results(self) -> None: + aas_list = [obj for obj in self.example_data if isinstance(obj, model.AssetAdministrationShell)] + known_id = aas_list[0].asset_information.global_asset_id + assert known_id is not None + unknown_id = "http://example.org/nonexistent_asset" + id1 = _encode_asset_id("globalAssetId", known_id) + id2 = _encode_asset_id("globalAssetId", unknown_id) + response = self.client.get(f"{BASE_PATH}/shells?assetIds={id1}&assetIds={id2}") + self.assertEqual(200, response.status_code) + result = json.loads(response.data) + returned_ids = [r["id"] for r in result] + self.assertIn(aas_list[0].id, returned_ids) + def test_malformed_asset_id_missing_field_returns_400(self) -> None: bad_payload = base64.urlsafe_b64encode(b'{"name": "globalAssetId"}').decode() - response = self.client.get(f"/api/v3.1/shells?assetIds={bad_payload}") + response = self.client.get(f"{BASE_PATH}/shells?assetIds={bad_payload}") self.assertEqual(400, response.status_code) From 2b3716a85c5a45006623c74fe42865077701ecb6 Mon Sep 17 00:00:00 2001 From: Henri Poeche Date: Mon, 18 May 2026 14:49:20 +0200 Subject: [PATCH 10/15] compliance_tool: remove unused schema check (#549) The compliance tool still had code to check a json or xml file against the defined schema from admin-shell-io/aas-specs-metamodel. The option to run this schema check was removed earlier from cli.py with commit af73a4b. As no other code uses the implemented schema check, this function is now removed, including the schema files and the unittests. --- .../compliance_check_aasx.py | 63 - .../compliance_check_json.py | 78 - .../compliance_check_xml.py | 77 - .../schemas/aasJSONSchema.json | 1528 ----------------- .../schemas/aasXMLSchema.xsd | 1344 --------------- .../test/test_compliance_check_json.py | 44 - .../test/test_compliance_check_xml.py | 35 - 7 files changed, 3169 deletions(-) delete mode 100644 compliance_tool/aas_compliance_tool/schemas/aasJSONSchema.json delete mode 100644 compliance_tool/aas_compliance_tool/schemas/aasXMLSchema.xsd diff --git a/compliance_tool/aas_compliance_tool/compliance_check_aasx.py b/compliance_tool/aas_compliance_tool/compliance_check_aasx.py index 0b10f5fe..40c4f2cd 100644 --- a/compliance_tool/aas_compliance_tool/compliance_check_aasx.py +++ b/compliance_tool/aas_compliance_tool/compliance_check_aasx.py @@ -88,69 +88,6 @@ def check_deserialization(file_path: str, state_manager: ComplianceToolStateMana return identifiable_store, files, new_cp -def check_schema(file_path: str, state_manager: ComplianceToolStateManager) -> None: - """ - Checks a given file against the official json schema and reports any issues using the given - :class:`~basyx.aas.compliance_tool.state_manager.ComplianceToolStateManager` - - Opens the file and checks if the data inside is stored in XML or JSON. Then calls the respective compliance tool - schema check - """ - logger = logging.getLogger('compliance_check') - logger.addHandler(state_manager) - logger.propagate = False - logger.setLevel(logging.INFO) - - # create handler to get logger info - logger_deserialization = logging.getLogger(aasx.__name__) - logger_deserialization.addHandler(state_manager) - logger_deserialization.propagate = False - logger_deserialization.setLevel(logging.INFO) - - state_manager.add_step('Open file') - try: - # open given file - reader = aasx.AASXReader(file_path) - state_manager.set_step_status_from_log() - except ValueError as error: - logger.error(error) - state_manager.set_step_status_from_log() - state_manager.add_step('Read file') - state_manager.set_step_status(Status.NOT_EXECUTED) - return - - try: - # read given file (Find XML and JSON parts) - state_manager.add_step('Read file') - core_rels = reader.reader.get_related_parts_by_type() - try: - aasx_origin_part = core_rels[aasx.RELATIONSHIP_TYPE_AASX_ORIGIN][0] - except IndexError as e: - raise ValueError("Not a valid AASX file: aasx-origin Relationship is missing.") from e - state_manager.set_step_status(Status.SUCCESS) - for aas_part in reader.reader.get_related_parts_by_type(aasx_origin_part)[ - aasx.RELATIONSHIP_TYPE_AAS_SPEC]: - content_type = reader.reader.get_content_type(aas_part) - extension = aas_part.split("/")[-1].split(".")[-1] - with reader.reader.open_part(aas_part) as p: - if content_type.split(";")[0] in ( - "text/xml", "application/xml") or content_type == "" and extension == "xml": - logger.debug("Parsing AAS objects from XML stream in OPC part {} ...".format(aas_part)) - compliance_check_xml._check_schema(p, state_manager) - elif content_type.split(";")[0] == "application/json" \ - or content_type == "" and extension == "json": - logger.debug("Parsing AAS objects from JSON stream in OPC part {} ...".format(aas_part)) - compliance_check_json._check_schema(io.TextIOWrapper(p, encoding='utf-8-sig'), state_manager) - else: - raise ValueError("Could not determine part format of AASX part {} (Content Type: {}, extension: {}" - .format(aas_part, content_type, extension)) - except ValueError as error: - logger.error(error) - state_manager.set_step_status(Status.FAILED) - finally: - reader.close() - - def check_aas_example(file_path: str, state_manager: ComplianceToolStateManager, **kwargs) -> None: """ Checks if a file contains all elements of the aas example and reports any issues using the given diff --git a/compliance_tool/aas_compliance_tool/compliance_check_json.py b/compliance_tool/aas_compliance_tool/compliance_check_json.py index b021fa96..e50332ec 100644 --- a/compliance_tool/aas_compliance_tool/compliance_check_json.py +++ b/compliance_tool/aas_compliance_tool/compliance_check_json.py @@ -23,84 +23,6 @@ from aas_compliance_tool.state_manager import ComplianceToolStateManager, Status -JSON_SCHEMA_FILE = os.path.join(os.path.dirname(__file__), 'schemas/aasJSONSchema.json') - - -def check_schema(file_path: str, state_manager: ComplianceToolStateManager) -> None: - """ - Checks a given file against the official json schema and reports any issues using the given - :class:`~basyx.aas.compliance_tool.state_manager.ComplianceToolStateManager` - - Add the steps: `Open file`, `Read file and check if it is conform to the json syntax` and `Validate file against - official json schema` - - :param file_path: Path to the file which should be checked - :param state_manager: :class:`~basyx.aas.compliance_tool.state_manager.ComplianceToolStateManager` to log the steps - """ - logger = logging.getLogger('compliance_check') - logger.addHandler(state_manager) - logger.propagate = False - logger.setLevel(logging.INFO) - - state_manager.add_step('Open file') - try: - # open given file - file_to_be_checked = open(file_path, 'r', encoding='utf-8-sig') - except IOError as error: - state_manager.set_step_status(Status.FAILED) - logger.error(error) - state_manager.add_step('Read file and check if it is conform to the json syntax') - state_manager.set_step_status(Status.NOT_EXECUTED) - state_manager.add_step('Validate file against official json schema') - state_manager.set_step_status(Status.NOT_EXECUTED) - return - return _check_schema(file_to_be_checked, state_manager) - - -def _check_schema(file_to_be_checked: IO[str], state_manager: ComplianceToolStateManager): - logger = logging.getLogger('compliance_check') - logger.addHandler(state_manager) - logger.propagate = False - logger.setLevel(logging.INFO) - - try: - with file_to_be_checked: - state_manager.set_step_status(Status.SUCCESS) - # read given file and check if it is conform to the json syntax - state_manager.add_step('Read file and check if it is conform to the json syntax') - json_to_be_checked = json.load(file_to_be_checked) - state_manager.set_step_status(Status.SUCCESS) - except json.decoder.JSONDecodeError as error: - state_manager.set_step_status(Status.FAILED) - logger.error(error) - state_manager.add_step('Validate file against official json schema') - state_manager.set_step_status(Status.NOT_EXECUTED) - return - - # load json schema - with open(JSON_SCHEMA_FILE, 'r', encoding='utf-8-sig') as json_file: - aas_json_schema = json.load(json_file) - state_manager.add_step('Validate file against official json schema') - - # validate given file against schema - try: - import jsonschema # type: ignore - except ImportError as error: - state_manager.set_step_status(Status.NOT_EXECUTED) - logger.error("Python package 'jsonschema' is required for validating the JSON file.", error) - return - - try: - jsonschema.validate(instance=json_to_be_checked, schema=aas_json_schema) - except jsonschema.exceptions.ValidationError as error: - state_manager.set_step_status(Status.FAILED) - logger.error(error) - return - - state_manager.set_step_status(Status.SUCCESS) - return - - def check_deserialization(file_path: str, state_manager: ComplianceToolStateManager, file_info: Optional[str] = None) -> model.DictIdentifiableStore: """ diff --git a/compliance_tool/aas_compliance_tool/compliance_check_xml.py b/compliance_tool/aas_compliance_tool/compliance_check_xml.py index 81f2b5ff..eeb9924c 100644 --- a/compliance_tool/aas_compliance_tool/compliance_check_xml.py +++ b/compliance_tool/aas_compliance_tool/compliance_check_xml.py @@ -23,83 +23,6 @@ from aas_compliance_tool.state_manager import ComplianceToolStateManager, Status -XML_SCHEMA_FILE = os.path.join(os.path.dirname(__file__), 'schemas/aasXMLSchema.xsd') - - -def check_schema(file_path: str, state_manager: ComplianceToolStateManager) -> None: - """ - Checks a given file against the official xml schema and reports any issues using the given - :class:`~basyx.aas.compliance_tool.state_manager.ComplianceToolStateManager` - - Add the steps: `Open file`, `Read file`, `Check if it is conform to the xml syntax` and `Validate file against - official xml schema` - - :param file_path: Path to the file which should be checked - :param state_manager: :class:`~basyx.aas.compliance_tool.state_manager.ComplianceToolStateManager` to log the steps - """ - logger = logging.getLogger('compliance_check') - logger.addHandler(state_manager) - logger.propagate = False - logger.setLevel(logging.INFO) - - state_manager.add_step('Open file') - try: - # open given file - file_to_be_checked = open(file_path, 'rb') - state_manager.set_step_status(Status.SUCCESS) - except IOError as error: - state_manager.set_step_status(Status.FAILED) - logger.error(error) - state_manager.add_step('Read file and check if it is conform to the xml syntax') - state_manager.set_step_status(Status.NOT_EXECUTED) - state_manager.add_step('Validate file against official xml schema') - state_manager.set_step_status(Status.NOT_EXECUTED) - return - return _check_schema(file_to_be_checked, state_manager) - - -def _check_schema(file_to_be_checked, state_manager): - logger = logging.getLogger('compliance_check') - logger.addHandler(state_manager) - logger.propagate = False - logger.setLevel(logging.INFO) - - state_manager.add_step('Read file and check if it is conform to the xml syntax') - try: - # read given file and check if it is conform to the xml syntax - parser = etree.XMLParser(remove_blank_text=True, remove_comments=True) - etree.parse(file_to_be_checked, parser) - state_manager.set_step_status(Status.SUCCESS) - except etree.XMLSyntaxError as error: - state_manager.set_step_status(Status.FAILED) - logger.error(error) - state_manager.add_step('Validate file against official xml schema') - state_manager.set_step_status(Status.NOT_EXECUTED) - file_to_be_checked.close() - return - except Exception: - file_to_be_checked.close() - raise - - # load aas xml schema - aas_xml_schema = etree.XMLSchema(file=XML_SCHEMA_FILE) - parser = etree.XMLParser(schema=aas_xml_schema) - - state_manager.add_step('Validate file against official xml schema') - # validate given file against schema - try: - file_to_be_checked.seek(0) # Reset reading file offset (cursor) to the beginning of the file - with file_to_be_checked: - etree.parse(file_to_be_checked, parser=parser) - except etree.ParseError as error: - state_manager.set_step_status(Status.FAILED) - logger.error(error) - return - - state_manager.set_step_status(Status.SUCCESS) - return - - def check_deserialization(file_path: str, state_manager: ComplianceToolStateManager, file_info: Optional[str] = None) -> model.DictIdentifiableStore: """ diff --git a/compliance_tool/aas_compliance_tool/schemas/aasJSONSchema.json b/compliance_tool/aas_compliance_tool/schemas/aasJSONSchema.json deleted file mode 100644 index 7ba1a360..00000000 --- a/compliance_tool/aas_compliance_tool/schemas/aasJSONSchema.json +++ /dev/null @@ -1,1528 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2019-09/schema", - "title": "AssetAdministrationShellEnvironment", - "type": "object", - "allOf": [ - { - "$ref": "#/definitions/Environment" - } - ], - "$id": "https://admin-shell.io/aas/3/1", - "definitions": { - "AasSubmodelElements": { - "type": "string", - "enum": [ - "AnnotatedRelationshipElement", - "BasicEventElement", - "Blob", - "Capability", - "DataElement", - "Entity", - "EventElement", - "File", - "MultiLanguageProperty", - "Operation", - "Property", - "Range", - "ReferenceElement", - "RelationshipElement", - "SubmodelElement", - "SubmodelElementCollection", - "SubmodelElementList" - ] - }, - "AbstractLangString": { - "type": "object", - "properties": { - "language": { - "type": "string", - "pattern": "^(([a-zA-Z]{2,3}(-[a-zA-Z]{3}(-[a-zA-Z]{3}){2})?|[a-zA-Z]{4}|[a-zA-Z]{5,8})(-[a-zA-Z]{4})?(-([a-zA-Z]{2}|[0-9]{3}))?(-(([a-zA-Z0-9]){5,8}|[0-9]([a-zA-Z0-9]){3}))*(-[0-9A-WY-Za-wy-z](-([a-zA-Z0-9]){2,8})+)*(-[xX](-([a-zA-Z0-9]){1,8})+)?|[xX](-([a-zA-Z0-9]){1,8})+|((en-GB-oed|i-ami|i-bnn|i-default|i-enochian|i-hak|i-klingon|i-lux|i-mingo|i-navajo|i-pwn|i-tao|i-tay|i-tsu|sgn-BE-FR|sgn-BE-NL|sgn-CH-DE)|(art-lojban|cel-gaulish|no-bok|no-nyn|zh-guoyu|zh-hakka|zh-min|zh-min-nan|zh-xiang)))$" - }, - "text": { - "type": "string", - "minLength": 1, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - } - }, - "required": [ - "language", - "text" - ] - }, - "AdministrativeInformation": { - "allOf": [ - { - "$ref": "#/definitions/HasDataSpecification" - }, - { - "properties": { - "version": { - "type": "string", - "allOf": [ - { - "minLength": 1, - "maxLength": 4 - }, - { - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - { - "pattern": "^(0|[1-9][0-9]*)$" - } - ] - }, - "revision": { - "type": "string", - "allOf": [ - { - "minLength": 1, - "maxLength": 4 - }, - { - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - { - "pattern": "^(0|[1-9][0-9]*)$" - } - ] - }, - "creator": { - "$ref": "#/definitions/Reference" - }, - "templateId": { - "type": "string", - "minLength": 1, - "maxLength": 2000, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - } - } - } - ] - }, - "AnnotatedRelationshipElement": { - "allOf": [ - { - "$ref": "#/definitions/RelationshipElement_abstract" - }, - { - "properties": { - "annotations": { - "type": "array", - "items": { - "$ref": "#/definitions/DataElement_choice" - }, - "minItems": 1 - }, - "modelType": { - "const": "AnnotatedRelationshipElement" - } - } - } - ] - }, - "AssetAdministrationShell": { - "allOf": [ - { - "$ref": "#/definitions/Identifiable" - }, - { - "$ref": "#/definitions/HasDataSpecification" - }, - { - "properties": { - "derivedFrom": { - "$ref": "#/definitions/Reference" - }, - "assetInformation": { - "$ref": "#/definitions/AssetInformation" - }, - "submodels": { - "type": "array", - "items": { - "$ref": "#/definitions/Reference" - }, - "minItems": 1 - }, - "modelType": { - "const": "AssetAdministrationShell" - } - }, - "required": [ - "assetInformation" - ] - } - ] - }, - "AssetInformation": { - "type": "object", - "properties": { - "assetKind": { - "$ref": "#/definitions/AssetKind" - }, - "globalAssetId": { - "type": "string", - "minLength": 1, - "maxLength": 2000, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "specificAssetIds": { - "type": "array", - "items": { - "$ref": "#/definitions/SpecificAssetId" - }, - "minItems": 1 - }, - "assetType": { - "type": "string", - "minLength": 1, - "maxLength": 2000, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "defaultThumbnail": { - "$ref": "#/definitions/Resource" - } - }, - "required": [ - "assetKind" - ] - }, - "AssetKind": { - "type": "string", - "enum": [ - "Instance", - "NotApplicable", - "Type" - ] - }, - "BasicEventElement": { - "allOf": [ - { - "$ref": "#/definitions/EventElement" - }, - { - "properties": { - "observed": { - "$ref": "#/definitions/Reference" - }, - "direction": { - "$ref": "#/definitions/Direction" - }, - "state": { - "$ref": "#/definitions/StateOfEvent" - }, - "messageTopic": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "messageBroker": { - "$ref": "#/definitions/Reference" - }, - "lastUpdate": { - "type": "string", - "pattern": "^-?(([1-9][0-9][0-9][0-9]+)|(0[0-9][0-9][0-9]))-((0[1-9])|(1[0-2]))-((0[1-9])|([12][0-9])|(3[01]))T(((([01][0-9])|(2[0-3])):[0-5][0-9]:([0-5][0-9])(\\.[0-9]+)?)|24:00:00(\\.0+)?)(Z|\\+00:00|-00:00)$" - }, - "minInterval": { - "type": "string", - "pattern": "^-?P((([0-9]+Y([0-9]+M)?([0-9]+D)?|([0-9]+M)([0-9]+D)?|([0-9]+D))(T(([0-9]+H)([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|([0-9]+M)([0-9]+(\\.[0-9]+)?S)?|([0-9]+(\\.[0-9]+)?S)))?)|(T(([0-9]+H)([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|([0-9]+M)([0-9]+(\\.[0-9]+)?S)?|([0-9]+(\\.[0-9]+)?S))))$" - }, - "maxInterval": { - "type": "string", - "pattern": "^-?P((([0-9]+Y([0-9]+M)?([0-9]+D)?|([0-9]+M)([0-9]+D)?|([0-9]+D))(T(([0-9]+H)([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|([0-9]+M)([0-9]+(\\.[0-9]+)?S)?|([0-9]+(\\.[0-9]+)?S)))?)|(T(([0-9]+H)([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|([0-9]+M)([0-9]+(\\.[0-9]+)?S)?|([0-9]+(\\.[0-9]+)?S))))$" - }, - "modelType": { - "const": "BasicEventElement" - } - }, - "required": [ - "observed", - "direction", - "state" - ] - } - ] - }, - "Blob": { - "allOf": [ - { - "$ref": "#/definitions/DataElement" - }, - { - "properties": { - "value": { - "type": "string", - "contentEncoding": "base64" - }, - "contentType": { - "type": "string", - "allOf": [ - { - "minLength": 1, - "maxLength": 100 - }, - { - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - { - "pattern": "^([!#$%&'*+\\-.^_`|~0-9a-zA-Z])+/([!#$%&'*+\\-.^_`|~0-9a-zA-Z])+([ \\t]*;[ \\t]*([!#$%&'*+\\-.^_`|~0-9a-zA-Z])+=(([!#$%&'*+\\-.^_`|~0-9a-zA-Z])+|\"(([\\t !#-\\[\\]-~]|[\u0080-\u00ff])|\\\\([\\t !-~]|[\u0080-\u00ff]))*\"))*$" - } - ] - }, - "modelType": { - "const": "Blob" - } - }, - "required": [ - "contentType" - ] - } - ] - }, - "Capability": { - "allOf": [ - { - "$ref": "#/definitions/SubmodelElement" - }, - { - "properties": { - "modelType": { - "const": "Capability" - } - } - } - ] - }, - "ConceptDescription": { - "allOf": [ - { - "$ref": "#/definitions/Identifiable" - }, - { - "$ref": "#/definitions/HasDataSpecification" - }, - { - "properties": { - "isCaseOf": { - "type": "array", - "items": { - "$ref": "#/definitions/Reference" - }, - "minItems": 1 - }, - "modelType": { - "const": "ConceptDescription" - } - } - } - ] - }, - "DataElement": { - "$ref": "#/definitions/SubmodelElement" - }, - "DataElement_choice": { - "oneOf": [ - { - "$ref": "#/definitions/Blob" - }, - { - "$ref": "#/definitions/File" - }, - { - "$ref": "#/definitions/MultiLanguageProperty" - }, - { - "$ref": "#/definitions/Property" - }, - { - "$ref": "#/definitions/Range" - }, - { - "$ref": "#/definitions/ReferenceElement" - } - ] - }, - "DataSpecificationContent": { - "type": "object", - "properties": { - "modelType": { - "$ref": "#/definitions/ModelType" - } - }, - "required": [ - "modelType" - ] - }, - "DataSpecificationContent_choice": { - "oneOf": [ - { - "$ref": "#/definitions/DataSpecificationIec61360" - } - ] - }, - "DataSpecificationIec61360": { - "allOf": [ - { - "$ref": "#/definitions/DataSpecificationContent" - }, - { - "properties": { - "preferredName": { - "type": "array", - "items": { - "$ref": "#/definitions/LangStringPreferredNameTypeIec61360" - }, - "minItems": 1 - }, - "shortName": { - "type": "array", - "items": { - "$ref": "#/definitions/LangStringShortNameTypeIec61360" - }, - "minItems": 1 - }, - "unit": { - "type": "string", - "minLength": 1, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "unitId": { - "$ref": "#/definitions/Reference" - }, - "sourceOfDefinition": { - "type": "string", - "minLength": 1, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "symbol": { - "type": "string", - "minLength": 1, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "dataType": { - "$ref": "#/definitions/DataTypeIec61360" - }, - "definition": { - "type": "array", - "items": { - "$ref": "#/definitions/LangStringDefinitionTypeIec61360" - }, - "minItems": 1 - }, - "valueFormat": { - "type": "string", - "minLength": 1, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "valueList": { - "$ref": "#/definitions/ValueList" - }, - "value": { - "type": "string", - "minLength": 1, - "maxLength": 2000, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "levelType": { - "$ref": "#/definitions/LevelType" - }, - "modelType": { - "const": "DataSpecificationIec61360" - } - }, - "required": [ - "preferredName" - ] - } - ] - }, - "DataTypeDefXsd": { - "type": "string", - "enum": [ - "xs:anyURI", - "xs:base64Binary", - "xs:boolean", - "xs:byte", - "xs:date", - "xs:dateTime", - "xs:decimal", - "xs:double", - "xs:duration", - "xs:float", - "xs:gDay", - "xs:gMonth", - "xs:gMonthDay", - "xs:gYear", - "xs:gYearMonth", - "xs:hexBinary", - "xs:int", - "xs:integer", - "xs:long", - "xs:negativeInteger", - "xs:nonNegativeInteger", - "xs:nonPositiveInteger", - "xs:positiveInteger", - "xs:short", - "xs:string", - "xs:time", - "xs:unsignedByte", - "xs:unsignedInt", - "xs:unsignedLong", - "xs:unsignedShort" - ] - }, - "DataTypeIec61360": { - "type": "string", - "enum": [ - "BLOB", - "BOOLEAN", - "DATE", - "FILE", - "HTML", - "INTEGER_COUNT", - "INTEGER_CURRENCY", - "INTEGER_MEASURE", - "IRDI", - "IRI", - "RATIONAL", - "RATIONAL_MEASURE", - "REAL_COUNT", - "REAL_CURRENCY", - "REAL_MEASURE", - "STRING", - "STRING_TRANSLATABLE", - "TIME", - "TIMESTAMP" - ] - }, - "Direction": { - "type": "string", - "enum": [ - "input", - "output" - ] - }, - "EmbeddedDataSpecification": { - "type": "object", - "properties": { - "dataSpecification": { - "$ref": "#/definitions/Reference" - }, - "dataSpecificationContent": { - "$ref": "#/definitions/DataSpecificationContent_choice" - } - }, - "required": [ - "dataSpecification", - "dataSpecificationContent" - ] - }, - "Entity": { - "allOf": [ - { - "$ref": "#/definitions/SubmodelElement" - }, - { - "properties": { - "statements": { - "type": "array", - "items": { - "$ref": "#/definitions/SubmodelElement_choice" - }, - "minItems": 1 - }, - "entityType": { - "$ref": "#/definitions/EntityType" - }, - "globalAssetId": { - "type": "string", - "minLength": 1, - "maxLength": 2000, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "specificAssetIds": { - "type": "array", - "items": { - "$ref": "#/definitions/SpecificAssetId" - }, - "minItems": 1 - }, - "modelType": { - "const": "Entity" - } - }, - "required": [ - "entityType" - ] - } - ] - }, - "EntityType": { - "type": "string", - "enum": [ - "CoManagedEntity", - "SelfManagedEntity" - ] - }, - "Environment": { - "type": "object", - "properties": { - "assetAdministrationShells": { - "type": "array", - "items": { - "$ref": "#/definitions/AssetAdministrationShell" - }, - "minItems": 1 - }, - "submodels": { - "type": "array", - "items": { - "$ref": "#/definitions/Submodel" - }, - "minItems": 1 - }, - "conceptDescriptions": { - "type": "array", - "items": { - "$ref": "#/definitions/ConceptDescription" - }, - "minItems": 1 - } - } - }, - "EventElement": { - "$ref": "#/definitions/SubmodelElement" - }, - "EventPayload": { - "type": "object", - "properties": { - "source": { - "$ref": "#/definitions/Reference" - }, - "sourceSemanticId": { - "$ref": "#/definitions/Reference" - }, - "observableReference": { - "$ref": "#/definitions/Reference" - }, - "observableSemanticId": { - "$ref": "#/definitions/Reference" - }, - "topic": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "subjectId": { - "$ref": "#/definitions/Reference" - }, - "timeStamp": { - "type": "string", - "pattern": "^-?(([1-9][0-9][0-9][0-9]+)|(0[0-9][0-9][0-9]))-((0[1-9])|(1[0-2]))-((0[1-9])|([12][0-9])|(3[01]))T(((([01][0-9])|(2[0-3])):[0-5][0-9]:([0-5][0-9])(\\.[0-9]+)?)|24:00:00(\\.0+)?)(Z|\\+00:00|-00:00)$" - }, - "payload": { - "type": "string", - "contentEncoding": "base64" - } - }, - "required": [ - "source", - "observableReference", - "timeStamp" - ] - }, - "Extension": { - "allOf": [ - { - "$ref": "#/definitions/HasSemantics" - }, - { - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "valueType": { - "$ref": "#/definitions/DataTypeDefXsd" - }, - "value": { - "type": "string" - }, - "refersTo": { - "type": "array", - "items": { - "$ref": "#/definitions/Reference" - }, - "minItems": 1 - } - }, - "required": [ - "name" - ] - } - ] - }, - "File": { - "allOf": [ - { - "$ref": "#/definitions/DataElement" - }, - { - "properties": { - "value": { - "type": "string" - }, - "contentType": { - "type": "string", - "allOf": [ - { - "minLength": 1, - "maxLength": 100 - }, - { - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - { - "pattern": "^([!#$%&'*+\\-.^_`|~0-9a-zA-Z])+/([!#$%&'*+\\-.^_`|~0-9a-zA-Z])+([ \\t]*;[ \\t]*([!#$%&'*+\\-.^_`|~0-9a-zA-Z])+=(([!#$%&'*+\\-.^_`|~0-9a-zA-Z])+|\"(([\\t !#-\\[\\]-~]|[\u0080-\u00ff])|\\\\([\\t !-~]|[\u0080-\u00ff]))*\"))*$" - } - ] - }, - "modelType": { - "const": "File" - } - }, - "required": [ - "contentType" - ] - } - ] - }, - "HasDataSpecification": { - "type": "object", - "properties": { - "embeddedDataSpecifications": { - "type": "array", - "items": { - "$ref": "#/definitions/EmbeddedDataSpecification" - }, - "minItems": 1 - } - } - }, - "HasExtensions": { - "type": "object", - "properties": { - "extensions": { - "type": "array", - "items": { - "$ref": "#/definitions/Extension" - }, - "minItems": 1 - } - } - }, - "HasKind": { - "type": "object", - "properties": { - "kind": { - "$ref": "#/definitions/ModellingKind" - } - } - }, - "HasSemantics": { - "type": "object", - "properties": { - "semanticId": { - "$ref": "#/definitions/Reference" - }, - "supplementalSemanticIds": { - "type": "array", - "items": { - "$ref": "#/definitions/Reference" - }, - "minItems": 1 - } - } - }, - "Identifiable": { - "allOf": [ - { - "$ref": "#/definitions/Referable" - }, - { - "properties": { - "administration": { - "$ref": "#/definitions/AdministrativeInformation" - }, - "id": { - "type": "string", - "minLength": 1, - "maxLength": 2000, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - } - }, - "required": [ - "id" - ] - } - ] - }, - "Key": { - "type": "object", - "properties": { - "type": { - "$ref": "#/definitions/KeyTypes" - }, - "value": { - "type": "string", - "minLength": 1, - "maxLength": 2000, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - } - }, - "required": [ - "type", - "value" - ] - }, - "KeyTypes": { - "type": "string", - "enum": [ - "AnnotatedRelationshipElement", - "AssetAdministrationShell", - "BasicEventElement", - "Blob", - "Capability", - "ConceptDescription", - "DataElement", - "Entity", - "EventElement", - "File", - "FragmentReference", - "GlobalReference", - "Identifiable", - "MultiLanguageProperty", - "Operation", - "Property", - "Range", - "Referable", - "ReferenceElement", - "RelationshipElement", - "Submodel", - "SubmodelElement", - "SubmodelElementCollection", - "SubmodelElementList" - ] - }, - "LangStringDefinitionTypeIec61360": { - "allOf": [ - { - "$ref": "#/definitions/AbstractLangString" - }, - { - "properties": { - "text": { - "maxLength": 1023 - } - } - } - ] - }, - "LangStringNameType": { - "allOf": [ - { - "$ref": "#/definitions/AbstractLangString" - }, - { - "properties": { - "text": { - "maxLength": 128 - } - } - } - ] - }, - "LangStringPreferredNameTypeIec61360": { - "allOf": [ - { - "$ref": "#/definitions/AbstractLangString" - }, - { - "properties": { - "text": { - "maxLength": 255 - } - } - } - ] - }, - "LangStringShortNameTypeIec61360": { - "allOf": [ - { - "$ref": "#/definitions/AbstractLangString" - }, - { - "properties": { - "text": { - "maxLength": 18 - } - } - } - ] - }, - "LangStringTextType": { - "allOf": [ - { - "$ref": "#/definitions/AbstractLangString" - }, - { - "properties": { - "text": { - "maxLength": 1023 - } - } - } - ] - }, - "LevelType": { - "type": "object", - "properties": { - "min": { - "type": "boolean" - }, - "nom": { - "type": "boolean" - }, - "typ": { - "type": "boolean" - }, - "max": { - "type": "boolean" - } - }, - "required": [ - "min", - "nom", - "typ", - "max" - ] - }, - "ModelType": { - "type": "string", - "enum": [ - "AnnotatedRelationshipElement", - "AssetAdministrationShell", - "BasicEventElement", - "Blob", - "Capability", - "ConceptDescription", - "DataSpecificationIec61360", - "Entity", - "File", - "MultiLanguageProperty", - "Operation", - "Property", - "Range", - "ReferenceElement", - "RelationshipElement", - "Submodel", - "SubmodelElementCollection", - "SubmodelElementList" - ] - }, - "ModellingKind": { - "type": "string", - "enum": [ - "Instance", - "Template" - ] - }, - "MultiLanguageProperty": { - "allOf": [ - { - "$ref": "#/definitions/DataElement" - }, - { - "properties": { - "value": { - "type": "array", - "items": { - "$ref": "#/definitions/LangStringTextType" - }, - "minItems": 1 - }, - "valueId": { - "$ref": "#/definitions/Reference" - }, - "modelType": { - "const": "MultiLanguageProperty" - } - } - } - ] - }, - "Operation": { - "allOf": [ - { - "$ref": "#/definitions/SubmodelElement" - }, - { - "properties": { - "inputVariables": { - "type": "array", - "items": { - "$ref": "#/definitions/OperationVariable" - }, - "minItems": 1 - }, - "outputVariables": { - "type": "array", - "items": { - "$ref": "#/definitions/OperationVariable" - }, - "minItems": 1 - }, - "inoutputVariables": { - "type": "array", - "items": { - "$ref": "#/definitions/OperationVariable" - }, - "minItems": 1 - }, - "modelType": { - "const": "Operation" - } - } - } - ] - }, - "OperationVariable": { - "type": "object", - "properties": { - "value": { - "$ref": "#/definitions/SubmodelElement_choice" - } - }, - "required": [ - "value" - ] - }, - "Property": { - "allOf": [ - { - "$ref": "#/definitions/DataElement" - }, - { - "properties": { - "valueType": { - "$ref": "#/definitions/DataTypeDefXsd" - }, - "value": { - "type": "string" - }, - "valueId": { - "$ref": "#/definitions/Reference" - }, - "modelType": { - "const": "Property" - } - }, - "required": [ - "valueType" - ] - } - ] - }, - "Qualifiable": { - "type": "object", - "properties": { - "qualifiers": { - "type": "array", - "items": { - "$ref": "#/definitions/Qualifier" - }, - "minItems": 1 - }, - "modelType": { - "$ref": "#/definitions/ModelType" - } - }, - "required": [ - "modelType" - ] - }, - "Qualifier": { - "allOf": [ - { - "$ref": "#/definitions/HasSemantics" - }, - { - "properties": { - "kind": { - "$ref": "#/definitions/QualifierKind" - }, - "type": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "valueType": { - "$ref": "#/definitions/DataTypeDefXsd" - }, - "value": { - "type": "string" - }, - "valueId": { - "$ref": "#/definitions/Reference" - } - }, - "required": [ - "type", - "valueType" - ] - } - ] - }, - "QualifierKind": { - "type": "string", - "enum": [ - "ConceptQualifier", - "TemplateQualifier", - "ValueQualifier" - ] - }, - "Range": { - "allOf": [ - { - "$ref": "#/definitions/DataElement" - }, - { - "properties": { - "valueType": { - "$ref": "#/definitions/DataTypeDefXsd" - }, - "min": { - "type": "string" - }, - "max": { - "type": "string" - }, - "modelType": { - "const": "Range" - } - }, - "required": [ - "valueType" - ] - } - ] - }, - "Referable": { - "allOf": [ - { - "$ref": "#/definitions/HasExtensions" - }, - { - "properties": { - "category": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "idShort": { - "type": "string", - "allOf": [ - { - "minLength": 1, - "maxLength": 128 - }, - { - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - { - "pattern": "^[a-zA-Z][a-zA-Z0-9_]*$" - } - ] - }, - "displayName": { - "type": "array", - "items": { - "$ref": "#/definitions/LangStringNameType" - }, - "minItems": 1 - }, - "description": { - "type": "array", - "items": { - "$ref": "#/definitions/LangStringTextType" - }, - "minItems": 1 - }, - "modelType": { - "$ref": "#/definitions/ModelType" - } - }, - "required": [ - "modelType" - ] - } - ] - }, - "Reference": { - "type": "object", - "properties": { - "type": { - "$ref": "#/definitions/ReferenceTypes" - }, - "referredSemanticId": { - "$ref": "#/definitions/Reference" - }, - "keys": { - "type": "array", - "items": { - "$ref": "#/definitions/Key" - }, - "minItems": 1 - } - }, - "required": [ - "type", - "keys" - ] - }, - "ReferenceElement": { - "allOf": [ - { - "$ref": "#/definitions/DataElement" - }, - { - "properties": { - "value": { - "$ref": "#/definitions/Reference" - }, - "modelType": { - "const": "ReferenceElement" - } - } - } - ] - }, - "ReferenceTypes": { - "type": "string", - "enum": [ - "ExternalReference", - "ModelReference" - ] - }, - "RelationshipElement": { - "allOf": [ - { - "$ref": "#/definitions/RelationshipElement_abstract" - }, - { - "properties": { - "modelType": { - "const": "RelationshipElement" - } - } - } - ] - }, - "RelationshipElement_abstract": { - "allOf": [ - { - "$ref": "#/definitions/SubmodelElement" - }, - { - "properties": { - "first": { - "$ref": "#/definitions/Reference" - }, - "second": { - "$ref": "#/definitions/Reference" - } - }, - "required": [ - "first", - "second" - ] - } - ] - }, - "RelationshipElement_choice": { - "oneOf": [ - { - "$ref": "#/definitions/RelationshipElement" - }, - { - "$ref": "#/definitions/AnnotatedRelationshipElement" - } - ] - }, - "Resource": { - "type": "object", - "properties": { - "path": { - "type": "string", - "allOf": [ - { - "minLength": 1, - "maxLength": 2000 - }, - { - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - { - "pattern": "^file:(//((localhost|(\\[((([0-9A-Fa-f]{1,4}:){6}([0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|::([0-9A-Fa-f]{1,4}:){5}([0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|([0-9A-Fa-f]{1,4})?::([0-9A-Fa-f]{1,4}:){4}([0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(([0-9A-Fa-f]{1,4}:)?[0-9A-Fa-f]{1,4})?::([0-9A-Fa-f]{1,4}:){3}([0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(([0-9A-Fa-f]{1,4}:){2}[0-9A-Fa-f]{1,4})?::([0-9A-Fa-f]{1,4}:){2}([0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(([0-9A-Fa-f]{1,4}:){3}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}:([0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(([0-9A-Fa-f]{1,4}:){4}[0-9A-Fa-f]{1,4})?::([0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(([0-9A-Fa-f]{1,4}:){5}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}|(([0-9A-Fa-f]{1,4}:){6}[0-9A-Fa-f]{1,4})?::)|[vV][0-9A-Fa-f]+\\.([a-zA-Z0-9\\-._~]|[!$&'()*+,;=]|:)+)\\]|([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])|([a-zA-Z0-9\\-._~]|%[0-9A-Fa-f][0-9A-Fa-f]|[!$&'()*+,;=])*)))?/((([a-zA-Z0-9\\-._~]|%[0-9A-Fa-f][0-9A-Fa-f]|[!$&'()*+,;=]|[:@]))+(/(([a-zA-Z0-9\\-._~]|%[0-9A-Fa-f][0-9A-Fa-f]|[!$&'()*+,;=]|[:@]))*)*)?|/((([a-zA-Z0-9\\-._~]|%[0-9A-Fa-f][0-9A-Fa-f]|[!$&'()*+,;=]|[:@]))+(/(([a-zA-Z0-9\\-._~]|%[0-9A-Fa-f][0-9A-Fa-f]|[!$&'()*+,;=]|[:@]))*)*)?)$" - } - ] - }, - "contentType": { - "type": "string", - "allOf": [ - { - "minLength": 1, - "maxLength": 100 - }, - { - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - { - "pattern": "^([!#$%&'*+\\-.^_`|~0-9a-zA-Z])+/([!#$%&'*+\\-.^_`|~0-9a-zA-Z])+([ \\t]*;[ \\t]*([!#$%&'*+\\-.^_`|~0-9a-zA-Z])+=(([!#$%&'*+\\-.^_`|~0-9a-zA-Z])+|\"(([\\t !#-\\[\\]-~]|[\u0080-\u00ff])|\\\\([\\t !-~]|[\u0080-\u00ff]))*\"))*$" - } - ] - } - }, - "required": [ - "path" - ] - }, - "SpecificAssetId": { - "allOf": [ - { - "$ref": "#/definitions/HasSemantics" - }, - { - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 64, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "value": { - "type": "string", - "minLength": 1, - "maxLength": 2000, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "externalSubjectId": { - "$ref": "#/definitions/Reference" - } - }, - "required": [ - "name", - "value" - ] - } - ] - }, - "StateOfEvent": { - "type": "string", - "enum": [ - "off", - "on" - ] - }, - "Submodel": { - "allOf": [ - { - "$ref": "#/definitions/Identifiable" - }, - { - "$ref": "#/definitions/HasKind" - }, - { - "$ref": "#/definitions/HasSemantics" - }, - { - "$ref": "#/definitions/Qualifiable" - }, - { - "$ref": "#/definitions/HasDataSpecification" - }, - { - "properties": { - "submodelElements": { - "type": "array", - "items": { - "$ref": "#/definitions/SubmodelElement_choice" - }, - "minItems": 1 - }, - "modelType": { - "const": "Submodel" - } - } - } - ] - }, - "SubmodelElement": { - "allOf": [ - { - "$ref": "#/definitions/Referable" - }, - { - "$ref": "#/definitions/HasSemantics" - }, - { - "$ref": "#/definitions/Qualifiable" - }, - { - "$ref": "#/definitions/HasDataSpecification" - } - ] - }, - "SubmodelElementCollection": { - "allOf": [ - { - "$ref": "#/definitions/SubmodelElement" - }, - { - "properties": { - "value": { - "type": "array", - "items": { - "$ref": "#/definitions/SubmodelElement_choice" - }, - "minItems": 1 - }, - "modelType": { - "const": "SubmodelElementCollection" - } - } - } - ] - }, - "SubmodelElementList": { - "allOf": [ - { - "$ref": "#/definitions/SubmodelElement" - }, - { - "properties": { - "orderRelevant": { - "type": "boolean" - }, - "semanticIdListElement": { - "$ref": "#/definitions/Reference" - }, - "typeValueListElement": { - "$ref": "#/definitions/AasSubmodelElements" - }, - "valueTypeListElement": { - "$ref": "#/definitions/DataTypeDefXsd" - }, - "value": { - "type": "array", - "items": { - "$ref": "#/definitions/SubmodelElement_choice" - }, - "minItems": 1 - }, - "modelType": { - "const": "SubmodelElementList" - } - }, - "required": [ - "typeValueListElement" - ] - } - ] - }, - "SubmodelElement_choice": { - "oneOf": [ - { - "$ref": "#/definitions/RelationshipElement" - }, - { - "$ref": "#/definitions/AnnotatedRelationshipElement" - }, - { - "$ref": "#/definitions/BasicEventElement" - }, - { - "$ref": "#/definitions/Blob" - }, - { - "$ref": "#/definitions/Capability" - }, - { - "$ref": "#/definitions/Entity" - }, - { - "$ref": "#/definitions/File" - }, - { - "$ref": "#/definitions/MultiLanguageProperty" - }, - { - "$ref": "#/definitions/Operation" - }, - { - "$ref": "#/definitions/Property" - }, - { - "$ref": "#/definitions/Range" - }, - { - "$ref": "#/definitions/ReferenceElement" - }, - { - "$ref": "#/definitions/SubmodelElementCollection" - }, - { - "$ref": "#/definitions/SubmodelElementList" - } - ] - }, - "ValueList": { - "type": "object", - "properties": { - "valueReferencePairs": { - "type": "array", - "items": { - "$ref": "#/definitions/ValueReferencePair" - }, - "minItems": 1 - } - }, - "required": [ - "valueReferencePairs" - ] - }, - "ValueReferencePair": { - "type": "object", - "properties": { - "value": { - "type": "string", - "minLength": 1, - "maxLength": 2000, - "pattern": "^([\\t\\n\\r -\ud7ff\ue000-\ufffd]|\\ud800[\\udc00-\\udfff]|[\\ud801-\\udbfe][\\udc00-\\udfff]|\\udbff[\\udc00-\\udfff])*$" - }, - "valueId": { - "$ref": "#/definitions/Reference" - } - }, - "required": [ - "value", - "valueId" - ] - } - } -} \ No newline at end of file diff --git a/compliance_tool/aas_compliance_tool/schemas/aasXMLSchema.xsd b/compliance_tool/aas_compliance_tool/schemas/aasXMLSchema.xsd deleted file mode 100644 index 95096ecb..00000000 --- a/compliance_tool/aas_compliance_tool/schemas/aasXMLSchema.xsd +++ /dev/null @@ -1,1344 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/compliance_tool/test/test_compliance_check_json.py b/compliance_tool/test/test_compliance_check_json.py index a63d3909..656d1e50 100644 --- a/compliance_tool/test/test_compliance_check_json.py +++ b/compliance_tool/test/test_compliance_check_json.py @@ -12,50 +12,6 @@ class ComplianceToolJsonTest(unittest.TestCase): - def test_check_schema(self) -> None: - manager = ComplianceToolStateManager() - script_dir = os.path.dirname(__file__) - file_path_1 = os.path.join(script_dir, 'files/test_not_found.json') - compliance_tool.check_schema(file_path_1, manager) - self.assertEqual(3, len(manager.steps)) - self.assertEqual(Status.FAILED, manager.steps[0].status) - self.assertEqual(Status.NOT_EXECUTED, manager.steps[1].status) - self.assertEqual(Status.NOT_EXECUTED, manager.steps[2].status) - self.assertIn("No such file or directory", manager.format_step(0, verbose_level=1)) - - manager.steps = [] - file_path_2 = os.path.join(script_dir, 'files/test_not_deserializable.json') - compliance_tool.check_schema(file_path_2, manager) - self.assertEqual(3, len(manager.steps)) - self.assertEqual(Status.SUCCESS, manager.steps[0].status) - self.assertEqual(Status.FAILED, manager.steps[1].status) - self.assertEqual(Status.NOT_EXECUTED, manager.steps[2].status) - self.assertIn("Expecting ',' delimiter: line 4 column 2 (char 54)", manager.format_step(1, verbose_level=1)) - - manager.steps = [] - file_path_3 = os.path.join(script_dir, 'files/test_empty.json') - compliance_tool.check_schema(file_path_3, manager) - self.assertEqual(3, len(manager.steps)) - self.assertEqual(Status.SUCCESS, manager.steps[0].status) - self.assertEqual(Status.SUCCESS, manager.steps[1].status) - self.assertEqual(Status.SUCCESS, manager.steps[2].status) - - manager.steps = [] - file_path_4 = os.path.join(script_dir, 'files/test_demo_full_example.json') - compliance_tool.check_schema(file_path_4, manager) - self.assertEqual(3, len(manager.steps)) - self.assertEqual(Status.SUCCESS, manager.steps[0].status) - self.assertEqual(Status.SUCCESS, manager.steps[1].status) - self.assertEqual(Status.SUCCESS, manager.steps[2].status) - - manager.steps = [] - file_path_5 = os.path.join(script_dir, 'files/test_demo_full_example_wrong_attribute.json') - compliance_tool.check_schema(file_path_5, manager) - self.assertEqual(3, len(manager.steps)) - self.assertEqual(Status.SUCCESS, manager.steps[0].status) - self.assertEqual(Status.SUCCESS, manager.steps[1].status) - self.assertEqual(Status.SUCCESS, manager.steps[2].status) - def test_check_deserialization(self) -> None: manager = ComplianceToolStateManager() script_dir = os.path.dirname(__file__) diff --git a/compliance_tool/test/test_compliance_check_xml.py b/compliance_tool/test/test_compliance_check_xml.py index c7b023cc..7f5fbecc 100644 --- a/compliance_tool/test/test_compliance_check_xml.py +++ b/compliance_tool/test/test_compliance_check_xml.py @@ -12,41 +12,6 @@ class ComplianceToolXmlTest(unittest.TestCase): - def test_check_schema(self) -> None: - manager = ComplianceToolStateManager() - script_dir = os.path.dirname(__file__) - file_path_1 = os.path.join(script_dir, 'files/test_not_found.xml') - compliance_tool.check_schema(file_path_1, manager) - self.assertEqual(3, len(manager.steps)) - self.assertEqual(Status.FAILED, manager.steps[0].status) - self.assertEqual(Status.NOT_EXECUTED, manager.steps[1].status) - self.assertEqual(Status.NOT_EXECUTED, manager.steps[2].status) - self.assertIn("No such file or directory", manager.format_step(0, verbose_level=1)) - - manager.steps = [] - file_path_2 = os.path.join(script_dir, 'files/test_empty.xml') - compliance_tool.check_schema(file_path_2, manager) - self.assertEqual(3, len(manager.steps)) - self.assertEqual(Status.SUCCESS, manager.steps[0].status) - self.assertEqual(Status.SUCCESS, manager.steps[1].status) - self.assertEqual(Status.SUCCESS, manager.steps[2].status) - - manager.steps = [] - file_path_3 = os.path.join(script_dir, 'files/test_demo_full_example.xml') - compliance_tool.check_schema(file_path_3, manager) - self.assertEqual(3, len(manager.steps)) - self.assertEqual(Status.SUCCESS, manager.steps[0].status) - self.assertEqual(Status.SUCCESS, manager.steps[1].status) - self.assertEqual(Status.SUCCESS, manager.steps[2].status) - - manager.steps = [] - file_path_4 = os.path.join(script_dir, 'files/test_demo_full_example_wrong_attribute.xml') - compliance_tool.check_schema(file_path_4, manager) - self.assertEqual(3, len(manager.steps)) - self.assertEqual(Status.SUCCESS, manager.steps[0].status) - self.assertEqual(Status.SUCCESS, manager.steps[1].status) - self.assertEqual(Status.SUCCESS, manager.steps[2].status) - def test_check_deserialization(self) -> None: manager = ComplianceToolStateManager() script_dir = os.path.dirname(__file__) From efea04693247690a40844298b1a1b36703aa8dfd Mon Sep 17 00:00:00 2001 From: Ornella33 <103257204+Ornella33@users.noreply.github.com> Date: Tue, 19 May 2026 10:31:57 +0200 Subject: [PATCH 11/15] Revert `ServerAASFromJsonDecoder` direct import (#550) Previously, `provider.py` imported `ServerAASFromJsonDecoder` directly from `app.adapter`, which caused a circular import. Reverted the import to use the `app.adapter` module reference instead, accessing `adapter.ServerAASFromJsonDecoder` at the call site. Co-authored-by: s-heppner --- server/app/model/provider.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/app/model/provider.py b/server/app/model/provider.py index 409570fe..472f0997 100644 --- a/server/app/model/provider.py +++ b/server/app/model/provider.py @@ -5,7 +5,7 @@ from basyx.aas import model from basyx.aas.model import provider as sdk_provider -from app.adapter import ServerAASFromJsonDecoder +from app import adapter from app.model import descriptor PathOrIO = Union[Path, IO] @@ -66,7 +66,7 @@ def load_directory(directory: Union[Path, str]) -> DictDescriptorStore: if not file.is_file() or file.suffix.lower() != ".json": continue with open(file) as f: - data = json.load(f, cls=ServerAASFromJsonDecoder) + data = json.load(f, cls=adapter.ServerAASFromJsonDecoder) for item in data.get("assetAdministrationShellDescriptors", []): if isinstance(item, descriptor.AssetAdministrationShellDescriptor): try: From bce50bcdb4e49823f465efb8cbd3bf4ccb9ee0c2 Mon Sep 17 00:00:00 2001 From: s-heppner Date: Mon, 1 Jun 2026 18:30:31 +0200 Subject: [PATCH 12/15] Fix mutation persistence in persistent backends (#553) PR #370 removed `Referable.commit()` and all call sites in the `server` handlers without replacing the write-back mechanism. Since then, any mutation to an object retrieved from `LocalFileIdentifiableStore`, `LocalFileDescriptorStore`, or `CouchDBIdentifiableStore` was silently lost on cache eviction, visible only within the same in-process `WeakValueDictionary` cache entry. A different uWSGI worker, or any request after the cache entry expired, would re-read the stale on-disk or on-database state. There was also a compounding bug: `get_item()` / `get_identifiable_by_hash()` always re-read from storage even on a cache hit, then called `update_from()` on the cached object, discarding any in-memory mutations even within the same request. This change fixes both issues across all three backends: - `get_identifiable_by_hash()` / `get_identifiable_by_couchdb_id()`: return the cached instance on a hit instead of overwriting it with a freshly-deserialized copy. - `get_item()`: check the cache first and return immediately on a hit. - Add `commit()` to `LocalFileIdentifiableStore` (re-serializes to .json), `LocalFileDescriptorStore` (same), and `CouchDBIdentifiableStore` (PUT with stored `_rev`, updates revision on success). - `AbstractObjectStore.commit()` is added as a no-op default so in-memory stores (`DictIdentifiableStore`) require no changes. All mutating handlers in `server/app/interfaces/repository.py` and `registry.py` now call `self.object_store.commit()` after each mutation. - A regression test `test_mutation_persistence` is added to `sdk/test/backend/test_local_file.py`. Fixes #552 --- sdk/basyx/aas/backend/couchdb.py | 43 ++++++++++++++++++---- sdk/basyx/aas/backend/local_file.py | 57 +++++++++++++++++++++++------ sdk/basyx/aas/model/provider.py | 18 +++++++++ sdk/test/backend/test_local_file.py | 27 ++++++++++++++ server/app/backend/local_file.py | 26 +++++++++---- server/app/interfaces/registry.py | 20 +++++----- server/app/interfaces/repository.py | 17 +++++++++ 7 files changed, 173 insertions(+), 35 deletions(-) diff --git a/sdk/basyx/aas/backend/couchdb.py b/sdk/basyx/aas/backend/couchdb.py index c2871aed..fe02345e 100644 --- a/sdk/basyx/aas/backend/couchdb.py +++ b/sdk/basyx/aas/backend/couchdb.py @@ -160,7 +160,6 @@ def get_identifiable_by_couchdb_id(self, couchdb_id: str) -> model.Identifiable: raise KeyError("No Identifiable with couchdb-id {} found in CouchDB database".format(couchdb_id)) from e raise - # Add CouchDB metadata (for later commits) to object obj = data['data'] if not isinstance(obj, model.Identifiable): raise CouchDBResponseError("The CouchDB document with id {} does not contain an identifiable AAS object." @@ -168,14 +167,10 @@ def get_identifiable_by_couchdb_id(self, couchdb_id: str) -> model.Identifiable: set_couchdb_revision("{}/{}/{}".format(self.url, self.database_name, urllib.parse.quote(couchdb_id, safe='')), data["_rev"]) - # If we still have a local replication of that object (since it is referenced from anywhere else), update that - # replication and return it. with self._object_cache_lock: if obj.id in self._object_cache: - old_obj = self._object_cache[obj.id] - old_obj.update_from(obj) - return old_obj - self._object_cache[obj.id] = obj + return self._object_cache[obj.id] + self._object_cache[obj.id] = obj return obj def get_item(self, identifier: model.Identifier) -> model.Identifiable: @@ -186,6 +181,9 @@ def get_item(self, identifier: model.Identifier) -> model.Identifiable: :raises CouchDBError: If error occur during the request to the CouchDB server (see ``_do_request()`` for details) """ + with self._object_cache_lock: + if identifier in self._object_cache: + return self._object_cache[identifier] try: return self.get_identifiable_by_couchdb_id(self._transform_id(identifier, False)) except KeyError as e: @@ -220,6 +218,37 @@ def add(self, x: model.Identifiable) -> None: with self._object_cache_lock: self._object_cache[x.id] = x + def commit(self, x: model.Identifiable) -> None: + """ + Write the current in-memory state of a stored object back to the CouchDB. + + :param x: The object to persist + :raises KeyError: If the object is not present in the store or no revision is known + :raises CouchDBConflictError: If the object was modified in the database since it was last fetched + :raises CouchDBError: If error occur during the request to the CouchDB server + (see ``_do_request()`` for details) + """ + doc_url = "{}/{}/{}".format(self.url, self.database_name, self._transform_id(x.id)) + rev = get_couchdb_revision(doc_url) + if rev is None: + raise KeyError("No revision found for object with id {} — not fetched from this store".format(x.id)) + data = json.dumps({'data': x}, cls=json_serialization.AASToJsonEncoder) + try: + response = self._do_request( + "{}?rev={}".format(doc_url, rev), + 'PUT', + {'Content-type': 'application/json'}, + data.encode('utf-8')) + set_couchdb_revision(doc_url, response["rev"]) + except CouchDBServerError as e: + if e.code == 404: + raise KeyError("No AAS object with id {} exists in CouchDB database".format(x.id)) from e + elif e.code == 409: + raise CouchDBConflictError( + "Object with id {} has been modified in the database since it was last fetched." + .format(x.id)) from e + raise + def discard(self, x: model.Identifiable, safe_delete=False) -> None: """ Delete an :class:`~basyx.aas.model.base.Identifiable` AAS object from the CouchDB database diff --git a/sdk/basyx/aas/backend/local_file.py b/sdk/basyx/aas/backend/local_file.py index 72d5605a..df21ddfe 100644 --- a/sdk/basyx/aas/backend/local_file.py +++ b/sdk/basyx/aas/backend/local_file.py @@ -16,6 +16,7 @@ import json import os import hashlib +import tempfile import threading import warnings import weakref @@ -31,6 +32,13 @@ class LocalFileIdentifiableStore(model.AbstractObjectStore[model.Identifier, mod """ An ObjectStore implementation for :class:`~basyx.aas.model.base.Identifiable` BaSyx Python SDK objects backed by a local file based local backend + + .. warning:: + This backend is intended for development and testing only. It provides no + concurrency control across processes: concurrent writes to the same object + (e.g. under a multi-worker WSGI server) will silently overwrite each other, + with the last writer winning and no error raised. Use a dedicated database + backend for any production deployment. """ def __init__(self, directory_path: str): """ @@ -68,21 +76,16 @@ def get_identifiable_by_hash(self, hash_: str) -> model.Identifiable: :raises KeyError: If the respective file could not be found """ - # Try to get the correct file try: with open("{}/{}.json".format(self.directory_path, hash_), "r") as file: data = json.load(file, cls=json_deserialization.AASFromJsonDecoder) obj = data["data"] except FileNotFoundError as e: raise KeyError("No Identifiable with hash {} found in local file database".format(hash_)) from e - # If we still have a local replication of that object (since it is referenced from anywhere else), update that - # replication and return it. with self._object_cache_lock: if obj.id in self._object_cache: - old_obj = self._object_cache[obj.id] - old_obj.update_from(obj) - return old_obj - self._object_cache[obj.id] = obj + return self._object_cache[obj.id] + self._object_cache[obj.id] = obj return obj def get_item(self, identifier: model.Identifier) -> model.Identifiable: @@ -91,11 +94,33 @@ def get_item(self, identifier: model.Identifier) -> model.Identifiable: :raises KeyError: If the respective file could not be found """ + with self._object_cache_lock: + if identifier in self._object_cache: + return self._object_cache[identifier] try: return self.get_identifiable_by_hash(self._transform_id(identifier)) except KeyError as e: raise KeyError("No Identifiable with id {} found in local file database".format(identifier)) from e + def _write_atomic(self, x: model.Identifiable) -> None: + """ + Serialize x to a temp file in the store directory, then atomically replace the final file. + + Using os.replace() (rename(2) on POSIX) ensures readers always see a complete file — never + a partially-written one from a crash or concurrent access mid-write. + """ + final_path = "{}/{}.json".format(self.directory_path, self._transform_id(x.id)) + tmp_fd, tmp_path = tempfile.mkstemp(dir=self.directory_path, suffix=".tmp") + try: + with os.fdopen(tmp_fd, "w") as tmp_file: + json.dump({"data": x}, tmp_file, cls=json_serialization.AASToJsonEncoder, indent=4) + os.replace(tmp_path, final_path) + # Catch all `Exception`s, as well as `KeyboardInterrupt` and `SystemExit` too, so the temp + # file is never left behind even if the process is being torn down: + except BaseException: + os.unlink(tmp_path) + raise + def add(self, x: model.Identifiable) -> None: """ Add an object to the store @@ -105,10 +130,20 @@ def add(self, x: model.Identifiable) -> None: logger.debug("Adding object %s to Local File Store ...", repr(x)) if os.path.exists("{}/{}.json".format(self.directory_path, self._transform_id(x.id))): raise KeyError("Identifiable with id {} already exists in local file database".format(x.id)) - with open("{}/{}.json".format(self.directory_path, self._transform_id(x.id)), "w") as file: - json.dump({"data": x}, file, cls=json_serialization.AASToJsonEncoder, indent=4) - with self._object_cache_lock: - self._object_cache[x.id] = x + self._write_atomic(x) + with self._object_cache_lock: + self._object_cache[x.id] = x + + def commit(self, x: model.Identifiable) -> None: + """ + Write the current in-memory state of a stored object back to its file. + + :param x: The object to persist + :raises KeyError: If the object is not present in the store + """ + if not os.path.exists("{}/{}.json".format(self.directory_path, self._transform_id(x.id))): + raise KeyError("No AAS object with id {} exists in local file database".format(x.id)) + self._write_atomic(x) def discard(self, x: model.Identifiable) -> None: """ diff --git a/sdk/basyx/aas/model/provider.py b/sdk/basyx/aas/model/provider.py index c48342c6..9a91a346 100644 --- a/sdk/basyx/aas/model/provider.py +++ b/sdk/basyx/aas/model/provider.py @@ -59,6 +59,18 @@ class AbstractObjectStore(AbstractObjectProvider[_KEY, _VALUE], MutableSet[_VALU def __init__(self): pass + def commit(self, x: _VALUE) -> None: + """ + Persist an in-memory mutation of a stored object back to the underlying storage. + + Persistent backends (e.g. file-based or database-backed stores) must override this to + write the updated object back to storage. In-memory stores should override this with an + explicit no-op to make the intent clear. + + :param x: The object whose current in-memory state should be persisted + """ + raise NotImplementedError() + def update(self, other: Iterable[_VALUE]) -> None: for x in other: self.add(x) @@ -146,6 +158,9 @@ def add(self, x: _IDENTIFIABLE) -> None: .format(x.id)) self._backend[x.id] = x + def commit(self, x: _IDENTIFIABLE) -> None: + pass + def discard(self, x: _IDENTIFIABLE) -> None: if self._backend.get(x.id) is x: del self._backend[x.id] @@ -223,6 +238,9 @@ def add(self, x: _IDENTIFIABLE) -> None: else: raise KeyError(f"Identifiable object with same id {x.id} is already stored in this store") + def commit(self, x: _IDENTIFIABLE) -> None: + pass + def discard(self, x: _IDENTIFIABLE) -> None: self._backend.discard(x) diff --git a/sdk/test/backend/test_local_file.py b/sdk/test/backend/test_local_file.py index f1080240..71447f61 100644 --- a/sdk/test/backend/test_local_file.py +++ b/sdk/test/backend/test_local_file.py @@ -4,6 +4,7 @@ # the LICENSE file of this project. # # SPDX-License-Identifier: MIT +import gc import os.path import shutil @@ -134,6 +135,32 @@ def test_iter_ignores_non_json_files(self) -> None: self.assertEqual(5, len(items)) os.remove(stray) + def test_mutation_persistence(self) -> None: + submodel = model.Submodel( + id_='https://example.org/MutationTest', + submodel_element={ + model.Property(id_short='Prop', value_type=model.datatypes.String, value='before') + } + ) + self.identifiable_store.add(submodel) + + retrieved = self.identifiable_store.get_item('https://example.org/MutationTest') + assert isinstance(retrieved, model.Submodel) + prop = retrieved.get_referable(['Prop']) + assert isinstance(prop, model.Property) + prop.update_from(model.Property(id_short='Prop', value_type=model.datatypes.String, value='after')) + self.identifiable_store.commit(retrieved) + + # Drop all strong references to evict the WeakValueDictionary cache + del submodel, retrieved, prop + gc.collect() + + fresh = self.identifiable_store.get_item('https://example.org/MutationTest') + assert isinstance(fresh, model.Submodel) + fresh_prop = fresh.get_referable(['Prop']) + assert isinstance(fresh_prop, model.Property) + self.assertEqual('after', fresh_prop.value) + def test_reload_discard(self) -> None: # Load example submodel example_submodel = create_example_submodel() diff --git a/server/app/backend/local_file.py b/server/app/backend/local_file.py index e55c08e6..e71a0e98 100644 --- a/server/app/backend/local_file.py +++ b/server/app/backend/local_file.py @@ -67,20 +67,15 @@ def get_descriptor_by_hash(self, hash_: str) -> _DESCRIPTOR_TYPE: :raises KeyError: If the respective file could not be found """ - # Try to get the correct file try: with open("{}/{}.json".format(self.directory_path, hash_), "r") as file: obj = json.load(file, cls=jsonization.ServerAASFromJsonDecoder) except FileNotFoundError as e: raise KeyError("No Descriptor with hash {} found in local file database".format(hash_)) from e - # If we still have a local replication of that object (since it is referenced from anywhere else), update that - # replication and return it. with self._object_cache_lock: if obj.id in self._object_cache: - old_obj = self._object_cache[obj.id] - old_obj.update_from(obj) - return old_obj - self._object_cache[obj.id] = obj + return self._object_cache[obj.id] + self._object_cache[obj.id] = obj return obj def get_item(self, identifier: model.Identifier) -> _DESCRIPTOR_TYPE: @@ -89,6 +84,9 @@ def get_item(self, identifier: model.Identifier) -> _DESCRIPTOR_TYPE: :raises KeyError: If the respective file could not be found """ + with self._object_cache_lock: + if identifier in self._object_cache: + return self._object_cache[identifier] try: return self.get_descriptor_by_hash(self._transform_id(identifier)) except KeyError as e: @@ -113,6 +111,20 @@ def add(self, x: _DESCRIPTOR_TYPE) -> None: with self._object_cache_lock: self._object_cache[x.id] = x + def commit(self, x: _DESCRIPTOR_TYPE) -> None: + """ + Write the current in-memory state of a stored descriptor back to its file. + + :param x: The descriptor to persist + :raises KeyError: If the descriptor is not present in the store + """ + if not os.path.exists("{}/{}.json".format(self.directory_path, self._transform_id(x.id))): + raise KeyError("No AAS Descriptor object with id {} exists in local file database".format(x.id)) + with open("{}/{}.json".format(self.directory_path, self._transform_id(x.id)), "w") as file: + serialized = json.loads(json.dumps(x, cls=jsonization.ServerAASToJsonEncoder)) + serialized["modelType"] = DESCRIPTOR_TYPE_TO_STRING[type(x)] + json.dump(serialized, file, indent=4) + def discard(self, x: _DESCRIPTOR_TYPE) -> None: """ Delete an :class:`~app.model.descriptor.Descriptor` AAS object from the local file store diff --git a/server/app/interfaces/registry.py b/server/app/interfaces/registry.py index 37ab9555..274602ff 100644 --- a/server/app/interfaces/registry.py +++ b/server/app/interfaces/registry.py @@ -180,7 +180,7 @@ def post_aas_descriptor( self.object_store.add(descriptor) except KeyError as e: raise Conflict(f"AssetAdministrationShellDescriptor with Identifier {descriptor.id} already exists!") from e - descriptor.commit() + self.object_store.commit(descriptor) created_resource_url = map_adapter.build( self.get_aas_descriptor_by_id, {"aas_id": descriptor.id}, force_external=True ) @@ -202,12 +202,12 @@ def put_aas_descriptor_by_id( request, server_model.AssetAdministrationShellDescriptor, is_stripped_request(request) ) ) - descriptor.commit() + self.object_store.commit(descriptor) return response_t() except NotFound: descriptor = HTTPApiDecoder.request_body(request, server_model.AssetAdministrationShellDescriptor, False) self.object_store.add(descriptor) - descriptor.commit() + self.object_store.commit(descriptor) created_resource_url = map_adapter.build( self.get_aas_descriptor_by_id, {"aas_id": descriptor.id}, force_external=True ) @@ -247,7 +247,7 @@ def post_submodel_descriptor_through_superpath( if any(sd.id == submodel_descriptor.id for sd in aas_descriptor.submodel_descriptors): raise Conflict(f"Submodel Descriptor with Identifier {submodel_descriptor.id} already exists!") aas_descriptor.submodel_descriptors.append(submodel_descriptor) - aas_descriptor.commit() + self.object_store.commit(aas_descriptor) created_resource_url = map_adapter.build( self.get_submodel_descriptor_by_id_through_superpath, {"aas_id": aas_descriptor.id, "submodel_id": submodel_descriptor.id}, @@ -269,14 +269,14 @@ def put_submodel_descriptor_by_id_through_superpath( submodel_descriptor.update_from( HTTPApiDecoder.request_body(request, server_model.SubmodelDescriptor, is_stripped_request(request)) ) - aas_descriptor.commit() + self.object_store.commit(aas_descriptor) return response_t() except NotFound: submodel_descriptor = HTTPApiDecoder.request_body( request, server_model.SubmodelDescriptor, is_stripped_request(request) ) aas_descriptor.submodel_descriptors.append(submodel_descriptor) - aas_descriptor.commit() + self.object_store.commit(aas_descriptor) created_resource_url = map_adapter.build( self.get_submodel_descriptor_by_id_through_superpath, {"aas_id": aas_descriptor.id, "submodel_id": submodel_descriptor.id}, @@ -293,7 +293,7 @@ def delete_submodel_descriptor_by_id_through_superpath( if submodel_descriptor is None: raise NotFound(f"Submodel Descriptor with Identifier {submodel_id} not found in AssetAdministrationShell!") aas_descriptor.submodel_descriptors.remove(submodel_descriptor) - aas_descriptor.commit() + self.object_store.commit(aas_descriptor) return response_t() # ------ Submodel REGISTRY ROUTES ------- @@ -321,7 +321,7 @@ def post_submodel_descriptor( self.object_store.add(submodel_descriptor) except KeyError as e: raise Conflict(f"Submodel Descriptor with Identifier {submodel_descriptor.id} already exists!") from e - submodel_descriptor.commit() + self.object_store.commit(submodel_descriptor) created_resource_url = map_adapter.build( self.get_submodel_descriptor_by_id, {"submodel_id": submodel_descriptor.id}, force_external=True ) @@ -335,14 +335,14 @@ def put_submodel_descriptor_by_id( submodel_descriptor.update_from( HTTPApiDecoder.request_body(request, server_model.SubmodelDescriptor, is_stripped_request(request)) ) - submodel_descriptor.commit() + self.object_store.commit(submodel_descriptor) return response_t() except NotFound: submodel_descriptor = HTTPApiDecoder.request_body( request, server_model.SubmodelDescriptor, is_stripped_request(request) ) self.object_store.add(submodel_descriptor) - submodel_descriptor.commit() + self.object_store.commit(submodel_descriptor) created_resource_url = map_adapter.build( self.get_submodel_descriptor_by_id, {"submodel_id": submodel_descriptor.id}, force_external=True ) diff --git a/server/app/interfaces/repository.py b/server/app/interfaces/repository.py index 89ad0d64..c604408e 100644 --- a/server/app/interfaces/repository.py +++ b/server/app/interfaces/repository.py @@ -559,6 +559,7 @@ def put_aas(self, request: Request, url_args: Dict, response_t: Type[APIResponse aas.update_from( HTTPApiDecoder.request_body(request, model.AssetAdministrationShell, is_stripped_request(request)) ) + self.object_store.commit(aas) return response_t() def delete_aas(self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs) -> Response: @@ -577,6 +578,7 @@ def put_aas_asset_information( ) -> Response: aas = self._get_shell(url_args) aas.asset_information = HTTPApiDecoder.request_body(request, model.AssetInformation, False) + self.object_store.commit(aas) return response_t() def get_aas_submodel_refs( @@ -595,6 +597,7 @@ def post_aas_submodel_refs(self, request: Request, url_args: Dict, response_t: T if sm_ref in aas.submodel: raise Conflict(f"{sm_ref!r} already exists!") aas.submodel.add(sm_ref) + self.object_store.commit(aas) created_resource_url = map_adapter.build(self.delete_aas_submodel_refs_specific, { "aas_id": aas.id, "submodel_id": sm_ref.key[0].value @@ -606,6 +609,7 @@ def delete_aas_submodel_refs_specific( ) -> Response: aas = self._get_shell(url_args) aas.submodel.remove(self._get_submodel_reference(aas, url_args["submodel_id"])) + self.object_store.commit(aas) return response_t() def put_aas_submodel_refs_submodel( @@ -619,9 +623,11 @@ def put_aas_submodel_refs_submodel( id_changed: bool = submodel.id != new_submodel.id # TODO: https://github.com/eclipse-basyx/basyx-python-sdk/issues/216 submodel.update_from(new_submodel) + self.object_store.commit(submodel) if id_changed: aas.submodel.remove(sm_ref) aas.submodel.add(model.ModelReference.from_referable(submodel)) + self.object_store.commit(aas) return response_t() def delete_aas_submodel_refs_submodel( @@ -632,6 +638,7 @@ def delete_aas_submodel_refs_submodel( submodel = self._resolve_reference(sm_ref) self.object_store.remove(submodel) aas.submodel.remove(sm_ref) + self.object_store.commit(aas) return response_t() def aas_submodel_refs_redirect( @@ -708,6 +715,7 @@ def get_submodels_reference( def put_submodel(self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs) -> Response: submodel = self._get_submodel(url_args) submodel.update_from(HTTPApiDecoder.request_body(request, model.Submodel, is_stripped_request(request))) + self.object_store.commit(submodel) return response_t() def get_submodel_submodel_elements( @@ -775,6 +783,7 @@ def post_submodel_submodel_elements_id_short_path( raise Conflict( f"SubmodelElement with idShort {new_submodel_element.id_short} already exists " f"within {parent}!" ) + self.object_store.commit(self._get_submodel(url_args)) submodel = self._get_submodel(url_args) id_short_path = url_args.get("id_shorts", []) created_resource_url = map_adapter.build( @@ -794,6 +803,7 @@ def put_submodel_submodel_elements_id_short_path( request, model.SubmodelElement, is_stripped_request(request) # type: ignore[type-abstract] ) submodel_element.update_from(new_submodel_element) + self.object_store.commit(self._get_submodel(url_args)) return response_t() def delete_submodel_submodel_elements_id_short_path( @@ -802,6 +812,7 @@ def delete_submodel_submodel_elements_id_short_path( sm_or_se = self._get_submodel_or_nested_submodel_element(url_args) parent: model.UniqueIdShortNamespace = self._expect_namespace(sm_or_se.parent, sm_or_se.id_short) self._namespace_submodel_element_op(parent, parent.remove_referable, sm_or_se.id_short) + self.object_store.commit(self._get_submodel(url_args)) return response_t() def get_submodel_submodel_element_attachment(self, request: Request, url_args: Dict, **_kwargs) -> Response: @@ -854,6 +865,7 @@ def put_submodel_submodel_element_attachment( ) submodel_element.value = self.file_store.add_file(filename, file_storage.stream, submodel_element.content_type) + self.object_store.commit(self._get_submodel(url_args)) return response_t() def delete_submodel_submodel_element_attachment( @@ -876,6 +888,7 @@ def delete_submodel_submodel_element_attachment( pass submodel_element.value = None + self.object_store.commit(self._get_submodel(url_args)) return response_t() def get_submodel_submodel_element_qualifiers( @@ -895,6 +908,7 @@ def post_submodel_submodel_element_qualifiers( if sm_or_se.qualifier.contains_id("type", qualifier.type): raise Conflict(f"Qualifier with type {qualifier.type} already exists!") sm_or_se.qualifier.add(qualifier) + self.object_store.commit(self._get_submodel(url_args)) created_resource_url = map_adapter.build( self.get_submodel_submodel_element_qualifiers, { @@ -918,6 +932,7 @@ def put_submodel_submodel_element_qualifiers( raise Conflict(f"A qualifier of type {new_qualifier.type!r} already exists for {sm_or_se!r}") sm_or_se.remove_qualifier_by_type(qualifier.type) sm_or_se.qualifier.add(new_qualifier) + self.object_store.commit(self._get_submodel(url_args)) if qualifier_type_changed: created_resource_url = map_adapter.build( self.get_submodel_submodel_element_qualifiers, @@ -937,6 +952,7 @@ def delete_submodel_submodel_element_qualifiers( sm_or_se = self._get_submodel_or_nested_submodel_element(url_args) qualifier_type = url_args["qualifier_type"] self._qualifiable_qualifier_op(sm_or_se, sm_or_se.remove_qualifier_by_type, qualifier_type) + self.object_store.commit(self._get_submodel(url_args)) return response_t() # --------- CONCEPT DESCRIPTION ROUTES --------- @@ -976,6 +992,7 @@ def put_concept_description( concept_description.update_from( HTTPApiDecoder.request_body(request, model.ConceptDescription, is_stripped_request(request)) ) + self.object_store.commit(concept_description) return response_t() def delete_concept_description( From 5220ddad93974d1fa2c1b7334f326fb7ae15d116 Mon Sep 17 00:00:00 2001 From: Henri Poeche Date: Thu, 4 Jun 2026 13:01:12 +0200 Subject: [PATCH 13/15] CI: Build docker images for all services and archs (#556) After refactoring the server package, the CI for building and releasing did not longer work. Additionally the registry and discovery services are not published. These changes fix the build in the release CI for the repository service and add the missing ones. Builds are now executed using QEMU for amd64, armv7 and arm64 to create a multi-platform image. Fixes #555 --- .github/workflows/release.yml | 116 ++++++++++++++++++++++++++++++++-- 1 file changed, 110 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2f147c17..715769f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,9 @@ on: release: types: [published] +env: + TARGET_PLATFORM: "linux/amd64,linux/arm/v7,linux/arm64" + jobs: sdk-publish: # This job publishes the package to PyPI @@ -59,7 +62,54 @@ jobs: with: password: ${{ secrets.PYPI_ORG_TOKEN }} - server-publish: + server-repository-publish: + # This job publishes the server docker image to DockerHub + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./server + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Extract Docker image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: eclipsebasyx/basyx-python-repository + # This fetches the latest git tag and adds an additional "latest" to it, so e.g. `2.1.0,latest` + tags: | + type=semver,pattern={{version}} + type=raw,value=latest + labels: | + org.opencontainers.image.title=BaSyx Python Repository Service + org.opencontainers.image.description=Eclipse BaSyx Python SDK - Repository HTTP Server + org.opencontainers.image.source=https://github.com/eclipse-basyx/basyx-python-sdk/tree/main/server + org.opencontainers.image.licenses=MIT + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKER_HUB_USER }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + + - name: Setup QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build and Push Repository Docker Image + uses: docker/build-push-action@v6 + with: + context: . + file: ./server/docker/repository/Dockerfile + platforms: ${{ env.TARGET_PLATFORM }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + server-discovery-publish: # This job publishes the server docker image to DockerHub runs-on: ubuntu-latest defaults: @@ -73,14 +123,14 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: eclipsebasyx/basyx-python-server + images: eclipsebasyx/basyx-python-discovery # This fetches the latest git tag and adds an additional "latest" to it, so e.g. `2.1.0,latest` tags: | type=semver,pattern={{version}} type=raw,value=latest labels: | - org.opencontainers.image.title=BaSyx Python Server - org.opencontainers.image.description=Eclipse BaSyx Python SDK - HTTP Server + org.opencontainers.image.title=BaSyx Python Discovery Service + org.opencontainers.image.description=Eclipse BaSyx Python SDK - Discovery HTTP Server org.opencontainers.image.source=https://github.com/eclipse-basyx/basyx-python-sdk/tree/main/server org.opencontainers.image.licenses=MIT @@ -90,11 +140,65 @@ jobs: username: ${{ secrets.DOCKER_HUB_USER }} password: ${{ secrets.DOCKER_HUB_TOKEN }} - - name: Build and Push Docker Image + - name: Setup QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build and Push Repository Docker Image + uses: docker/build-push-action@v6 + with: + context: . + file: ./server/docker/discovery/Dockerfile + platforms: ${{ env.TARGET_PLATFORM }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + server-registry-publish: + # This job publishes the server docker image to DockerHub + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./server + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Extract Docker image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: eclipsebasyx/basyx-python-registry + # This fetches the latest git tag and adds an additional "latest" to it, so e.g. `2.1.0,latest` + tags: | + type=semver,pattern={{version}} + type=raw,value=latest + labels: | + org.opencontainers.image.title=BaSyx Python Registry Service + org.opencontainers.image.description=Eclipse BaSyx Python SDK - Registry HTTP Server + org.opencontainers.image.source=https://github.com/eclipse-basyx/basyx-python-sdk/tree/main/server + org.opencontainers.image.licenses=MIT + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKER_HUB_USER }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + + - name: Setup QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build and Push Repository Docker Image uses: docker/build-push-action@v6 with: context: . - file: ./server/Dockerfile # Todo: Update paths + file: ./server/docker/registry/Dockerfile + platforms: ${{ env.TARGET_PLATFORM }} push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} From 65802ea4cce07e0602c89e3eb545824d75ae7a17 Mon Sep 17 00:00:00 2001 From: Joshua Benning <54218874+JAB1305@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:05:52 +0200 Subject: [PATCH 14/15] test_provider: add tests for AbstractObjectStore.sync() (#557) Previously, the `sync()` method in `model.provider.AbstractObjectStore` was not tested at all. This adds a unittest with full line coverage of this method. --- sdk/test/model/test_provider.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/sdk/test/model/test_provider.py b/sdk/test/model/test_provider.py index 10947c16..2cefda23 100644 --- a/sdk/test/model/test_provider.py +++ b/sdk/test/model/test_provider.py @@ -55,6 +55,24 @@ def test_store_update(self) -> None: self.assertIsInstance(identifiable_store1, model.DictIdentifiableStore) self.assertIn(self.aas2, identifiable_store1) + def test_store_sync(self) -> None: + aas_identifiable_store: model.DictIdentifiableStore[model.Identifiable] = model.DictIdentifiableStore() + + self.assertEqual(aas_identifiable_store.sync([self.aas1, self.aas2], overwrite=False), (2, 0, 0)) + self.assertIn(self.aas1, aas_identifiable_store) + self.assertIn(self.aas2, aas_identifiable_store) + + self.assertEqual(aas_identifiable_store.sync([self.aas1], overwrite=False), (0, 0, 1)) + + self.assertEqual(aas_identifiable_store.sync([self.aas1], overwrite=True), (0, 1, 0)) + self.assertIn(self.aas1, aas_identifiable_store) + + self.assertEqual(aas_identifiable_store.sync([self.aas1, self.submodel1], overwrite=True), (1, 1, 0)) + + self.assertEqual(aas_identifiable_store.sync([self.aas1, self.submodel2], overwrite=False), (1, 0, 1)) + + self.assertEqual(aas_identifiable_store.sync([], overwrite=False), (0, 0, 0)) + def test_provider_multiplexer(self) -> None: aas_identifiable_store: model.DictIdentifiableStore[model.Identifiable] = ( model.DictIdentifiableStore() From e797e7fff4c7fe8e2e0e7fbe09b8b1f3a85cf815 Mon Sep 17 00:00:00 2001 From: Tamas Farkas Date: Mon, 15 Jun 2026 15:00:42 +0200 Subject: [PATCH 15/15] Fix PUT/DELETE submodel-element-by-id-short-path not persisting changes put_submodel_submodel_elements_id_short_path and delete_submodel_submodel_elements_id_short_path each fetched the submodel twice: once (indirectly) to locate/mutate the target element, and again to pass to object_store.commit(). For object stores that deserialize a fresh object graph on every get (e.g. Neo4jObjectStore), these are two distinct instances, so the mutation made on the first instance was never part of the object passed to commit() and the change was silently lost. Fetch the submodel once, navigate to the nested element within that same instance, mutate it, and commit that same submodel instance. --- server/app/interfaces/repository.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/server/app/interfaces/repository.py b/server/app/interfaces/repository.py index 38dbe53e..59622920 100644 --- a/server/app/interfaces/repository.py +++ b/server/app/interfaces/repository.py @@ -830,23 +830,25 @@ def post_submodel_submodel_elements_id_short_path( def put_submodel_submodel_elements_id_short_path( self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs ) -> Response: - submodel_element = self._get_submodel_submodel_elements_id_short_path(url_args) + submodel = self._get_submodel(url_args) + submodel_element = self._get_nested_submodel_element(submodel, url_args["id_shorts"]) # TODO: remove the following type: ignore comment when mypy supports abstract types for Type[T] # see https://github.com/python/mypy/issues/5374 new_submodel_element = HTTPApiDecoder.request_body( request, model.SubmodelElement, is_stripped_request(request) # type: ignore[type-abstract] ) submodel_element.update_from(new_submodel_element) - self.object_store.commit(self._get_submodel(url_args)) + self.object_store.commit(submodel) return response_t() def delete_submodel_submodel_elements_id_short_path( self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs ) -> Response: - sm_or_se = self._get_submodel_or_nested_submodel_element(url_args) + submodel = self._get_submodel(url_args) + sm_or_se = self._get_nested_submodel_element(submodel, url_args["id_shorts"]) parent: model.UniqueIdShortNamespace = self._expect_namespace(sm_or_se.parent, sm_or_se.id_short) self._namespace_submodel_element_op(parent, parent.remove_referable, sm_or_se.id_short) - self.object_store.commit(self._get_submodel(url_args)) + self.object_store.commit(submodel) return response_t() def get_submodel_submodel_element_attachment(self, request: Request, url_args: Dict, **_kwargs) -> Response: