From b4be7bca5a32d4afbb524d1948bb8374104f8b5d Mon Sep 17 00:00:00 2001 From: Tamas Farkas Date: Fri, 22 May 2026 10:07:31 +0200 Subject: [PATCH 1/3] Fix PUT not persisting changes in LocalFileIdentifiableStore update_from() only mutated the in-memory object; the next GET re-read the unchanged file from disk and overwrote the in-memory state, silently discarding the update. Add LocalFileIdentifiableStore.commit() to write an identifiable back to its storage file, and call it from put_submodel and put_submodel_submodel_elements_id_short_path after update_from(). --- sdk/basyx/aas/backend/local_file.py | 16 ++++++++++++++++ server/app/interfaces/repository.py | 5 +++++ 2 files changed, 21 insertions(+) diff --git a/sdk/basyx/aas/backend/local_file.py b/sdk/basyx/aas/backend/local_file.py index 72d5605a..1c5e5971 100644 --- a/sdk/basyx/aas/backend/local_file.py +++ b/sdk/basyx/aas/backend/local_file.py @@ -110,6 +110,22 @@ 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 an updated :class:`~basyx.aas.model.base.Identifiable` object back to its storage file. + + Use this after mutating an object that was retrieved from the store to persist the changes to disk. + + :param x: The object to persist + :raises KeyError: If the object does not exist in the database + """ + logger.debug("Committing object %s to Local File Store ...", repr(x)) + path = "{}/{}.json".format(self.directory_path, self._transform_id(x.id)) + if not os.path.exists(path): + raise KeyError("No AAS object with id {} exists in local file database".format(x.id)) + with open(path, "w") as file: + json.dump({"data": x}, file, cls=json_serialization.AASToJsonEncoder, indent=4) + def discard(self, x: model.Identifiable) -> None: """ Delete an :class:`~basyx.aas.model.base.Identifiable` AAS object from the local file store diff --git a/server/app/interfaces/repository.py b/server/app/interfaces/repository.py index 89ad0d64..faff38cc 100644 --- a/server/app/interfaces/repository.py +++ b/server/app/interfaces/repository.py @@ -708,6 +708,8 @@ 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))) + if hasattr(self.object_store, 'commit'): + self.object_store.commit(submodel) return response_t() def get_submodel_submodel_elements( @@ -787,6 +789,7 @@ 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 = self._get_submodel(url_args) submodel_element = self._get_submodel_submodel_elements_id_short_path(url_args) # TODO: remove the following type: ignore comment when mypy supports abstract types for Type[T] # see https://github.com/python/mypy/issues/5374 @@ -794,6 +797,8 @@ 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) + if hasattr(self.object_store, 'commit'): + self.object_store.commit(submodel) return response_t() def delete_submodel_submodel_elements_id_short_path( From 0005995caebcb0a40de5c97207384bdea047ea76 Mon Sep 17 00:00:00 2001 From: Tamas Farkas Date: Fri, 22 May 2026 12:07:55 +0200 Subject: [PATCH 2/3] Implement PATCH endpoints for submodels and submodel elements Six routes were previously returning 501 Not Implemented: - PATCH /submodels/{smId} - PATCH /submodels/{smId}/$metadata - PATCH /submodels/{smId}/$value - PATCH /submodels/{smId}/submodel-elements/{path} - PATCH /submodels/{smId}/submodel-elements/{path}/$metadata - PATCH /submodels/{smId}/submodel-elements/{path}/$value The plain and $metadata variants decode the full/stripped body and call update_from. The $value variants use a new _apply_value_only helper that applies a ValueOnly patch to the in-memory element tree. All handlers call commit() to persist changes to disk. --- server/app/interfaces/repository.py | 126 ++++++++++++++++++++++++++-- 1 file changed, 117 insertions(+), 9 deletions(-) diff --git a/server/app/interfaces/repository.py b/server/app/interfaces/repository.py index faff38cc..2245140a 100644 --- a/server/app/interfaces/repository.py +++ b/server/app/interfaces/repository.py @@ -8,9 +8,10 @@ This module implements the "Specification of the Asset Administration Shell Part 2 Application Programming Interfaces". """ +import base64 import io import json -from typing import Callable, Dict, Iterable, Iterator, List, Optional, Tuple, Type, Union +from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Tuple, Type, Union import werkzeug.exceptions import werkzeug.routing @@ -124,14 +125,14 @@ def __init__( Rule("/", methods=["GET"], endpoint=self.get_submodel), Rule("/", methods=["PUT"], endpoint=self.put_submodel), Rule("/", methods=["DELETE"], endpoint=self.delete_submodel), - Rule("/", methods=["PATCH"], endpoint=self.not_implemented), + Rule("/", methods=["PATCH"], endpoint=self.patch_submodel), Submount( "/", [ Rule("/$metadata", methods=["GET"], endpoint=self.get_submodels_metadata), - Rule("/$metadata", methods=["PATCH"], endpoint=self.not_implemented), + Rule("/$metadata", methods=["PATCH"], endpoint=self.patch_submodel_metadata), Rule("/$value", methods=["GET"], endpoint=self.not_implemented), - Rule("/$value", methods=["PATCH"], endpoint=self.not_implemented), + Rule("/$value", methods=["PATCH"], endpoint=self.patch_submodel_value), Rule("/$reference", methods=["GET"], endpoint=self.get_submodels_reference), Rule("/$path", methods=["GET"], endpoint=self.not_implemented), Rule( @@ -182,7 +183,7 @@ def __init__( Rule( "/", methods=["PATCH"], - endpoint=self.not_implemented, + endpoint=self.patch_submodel_submodel_elements_id_short_path, ), Submount( "/", @@ -195,7 +196,7 @@ def __init__( Rule( "/$metadata", methods=["PATCH"], - endpoint=self.not_implemented, + endpoint=self.patch_submodel_submodel_elements_id_short_path_metadata, # noqa: E501 ), Rule( "/$reference", @@ -204,7 +205,8 @@ def __init__( ), Rule("/$value", methods=["GET"], endpoint=self.not_implemented), Rule( - "/$value", methods=["PATCH"], endpoint=self.not_implemented + "/$value", methods=["PATCH"], + endpoint=self.patch_submodel_submodel_elements_id_short_path_value, # noqa: E501 ), Rule("/$path", methods=["GET"], endpoint=self.not_implemented), Rule( @@ -708,7 +710,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))) - if hasattr(self.object_store, 'commit'): + if hasattr(self.object_store, "commit"): self.object_store.commit(submodel) return response_t() @@ -797,10 +799,116 @@ 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) - if hasattr(self.object_store, 'commit'): + if hasattr(self.object_store, "commit"): + self.object_store.commit(submodel) + return response_t() + + def patch_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))) + if hasattr(self.object_store, "commit"): + self.object_store.commit(submodel) + return response_t() + + def patch_submodel_metadata( + 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, True)) + if hasattr(self.object_store, "commit"): + self.object_store.commit(submodel) + return response_t() + + def patch_submodel_value( + self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs + ) -> Response: + submodel = self._get_submodel(url_args) + value_data = json.loads(request.get_data()) + if not isinstance(value_data, dict): + raise BadRequest("ValueOnly body for a submodel must be a JSON object") + for id_short, child_value in value_data.items(): + child = next((e for e in submodel.submodel_element if e.id_short == id_short), None) + if child is None: + raise BadRequest(f"No submodel element with idShort {id_short!r}") + self._apply_value_only(child, child_value) + if hasattr(self.object_store, "commit"): + self.object_store.commit(submodel) + return response_t() + + def patch_submodel_submodel_elements_id_short_path( + self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs + ) -> Response: + submodel = self._get_submodel(url_args) + submodel_element = self._get_submodel_submodel_elements_id_short_path(url_args) + new_submodel_element = HTTPApiDecoder.request_body( + request, model.SubmodelElement, is_stripped_request(request) # type: ignore[type-abstract] + ) + submodel_element.update_from(new_submodel_element) + if hasattr(self.object_store, "commit"): self.object_store.commit(submodel) return response_t() + def patch_submodel_submodel_elements_id_short_path_metadata( + self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs + ) -> Response: + submodel = self._get_submodel(url_args) + submodel_element = self._get_submodel_submodel_elements_id_short_path(url_args) + new_submodel_element = HTTPApiDecoder.request_body( + request, model.SubmodelElement, True # type: ignore[type-abstract] + ) + submodel_element.update_from(new_submodel_element) + if hasattr(self.object_store, "commit"): + self.object_store.commit(submodel) + return response_t() + + def patch_submodel_submodel_elements_id_short_path_value( + self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs + ) -> Response: + submodel = self._get_submodel(url_args) + submodel_element = self._get_submodel_submodel_elements_id_short_path(url_args) + value_data = json.loads(request.get_data()) + self._apply_value_only(submodel_element, value_data) + if hasattr(self.object_store, "commit"): + self.object_store.commit(submodel) + return response_t() + + def _apply_value_only(self, element: model.SubmodelElement, value_data: Any) -> None: + if isinstance(element, model.Property): + element.value = ( + model.datatypes.from_xsd(str(value_data), element.value_type) + if value_data is not None else None + ) + elif isinstance(element, model.MultiLanguageProperty): + element.value = ( + model.MultiLanguageTextType({item["language"]: item["text"] for item in value_data}) + if value_data is not None else None + ) + elif isinstance(element, model.Range): + element.min = model.datatypes.from_xsd(str(value_data["min"]), element.value_type) + element.max = model.datatypes.from_xsd(str(value_data["max"]), element.value_type) + elif isinstance(element, model.Blob): + element.value = base64.b64decode(value_data) if value_data is not None else None + elif isinstance(element, model.File): + element.value = value_data + elif isinstance(element, model.SubmodelElementCollection): + if not isinstance(value_data, dict): + raise BadRequest("ValueOnly for SubmodelElementCollection must be a JSON object") + for id_short, child_value in value_data.items(): + child = next((e for e in element.submodel_element if e.id_short == id_short), None) + if child is None: + raise BadRequest(f"No submodel element with idShort {id_short!r} in {element.id_short!r}") + self._apply_value_only(child, child_value) + elif isinstance(element, model.SubmodelElementList): + if not isinstance(value_data, list): + raise BadRequest("ValueOnly for SubmodelElementList must be a JSON array") + elements = list(element.value) + for i, child_value in enumerate(value_data): + if i >= len(elements): + raise BadRequest(f"Index {i} out of range for SubmodelElementList {element.id_short!r}") + self._apply_value_only(elements[i], child_value) + else: + raise BadRequest(f"ValueOnly PATCH not supported for {type(element).__name__}") + def delete_submodel_submodel_elements_id_short_path( self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs ) -> Response: From bd9b8256bcc358a375ed3f93b73ec72ec24b6891 Mon Sep 17 00:00:00 2001 From: Tamas Farkas Date: Fri, 22 May 2026 12:24:48 +0200 Subject: [PATCH 3/3] Fix SubmodelElementCollection attribute name in _apply_value_only --- server/app/interfaces/repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/app/interfaces/repository.py b/server/app/interfaces/repository.py index 2245140a..c7db5a33 100644 --- a/server/app/interfaces/repository.py +++ b/server/app/interfaces/repository.py @@ -894,7 +894,7 @@ def _apply_value_only(self, element: model.SubmodelElement, value_data: Any) -> if not isinstance(value_data, dict): raise BadRequest("ValueOnly for SubmodelElementCollection must be a JSON object") for id_short, child_value in value_data.items(): - child = next((e for e in element.submodel_element if e.id_short == id_short), None) + child = next((e for e in element.value if e.id_short == id_short), None) if child is None: raise BadRequest(f"No submodel element with idShort {id_short!r} in {element.id_short!r}") self._apply_value_only(child, child_value)