Skip to content

Commit 4e0aeaa

Browse files
committed
Merge remote-tracking branch 'origin/develop' into feat/aasql-query-endpoints
2 parents dc7201b + 65802ea commit 4e0aeaa

9 files changed

Lines changed: 301 additions & 41 deletions

File tree

.github/workflows/release.yml

Lines changed: 110 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ on:
44
release:
55
types: [published]
66

7+
env:
8+
TARGET_PLATFORM: "linux/amd64,linux/arm/v7,linux/arm64"
9+
710
jobs:
811
sdk-publish:
912
# This job publishes the package to PyPI
@@ -59,7 +62,54 @@ jobs:
5962
with:
6063
password: ${{ secrets.PYPI_ORG_TOKEN }}
6164

62-
server-publish:
65+
server-repository-publish:
66+
# This job publishes the server docker image to DockerHub
67+
runs-on: ubuntu-latest
68+
defaults:
69+
run:
70+
working-directory: ./server
71+
steps:
72+
- name: Checkout Repository
73+
uses: actions/checkout@v4
74+
75+
- name: Extract Docker image metadata
76+
id: meta
77+
uses: docker/metadata-action@v5
78+
with:
79+
images: eclipsebasyx/basyx-python-repository
80+
# This fetches the latest git tag and adds an additional "latest" to it, so e.g. `2.1.0,latest`
81+
tags: |
82+
type=semver,pattern={{version}}
83+
type=raw,value=latest
84+
labels: |
85+
org.opencontainers.image.title=BaSyx Python Repository Service
86+
org.opencontainers.image.description=Eclipse BaSyx Python SDK - Repository HTTP Server
87+
org.opencontainers.image.source=https://github.com/eclipse-basyx/basyx-python-sdk/tree/main/server
88+
org.opencontainers.image.licenses=MIT
89+
90+
- name: Log in to Docker Hub
91+
uses: docker/login-action@v4
92+
with:
93+
username: ${{ secrets.DOCKER_HUB_USER }}
94+
password: ${{ secrets.DOCKER_HUB_TOKEN }}
95+
96+
- name: Setup QEMU
97+
uses: docker/setup-qemu-action@v4
98+
99+
- name: Set up Docker Buildx
100+
uses: docker/setup-buildx-action@v4
101+
102+
- name: Build and Push Repository Docker Image
103+
uses: docker/build-push-action@v6
104+
with:
105+
context: .
106+
file: ./server/docker/repository/Dockerfile
107+
platforms: ${{ env.TARGET_PLATFORM }}
108+
push: true
109+
tags: ${{ steps.meta.outputs.tags }}
110+
labels: ${{ steps.meta.outputs.labels }}
111+
112+
server-discovery-publish:
63113
# This job publishes the server docker image to DockerHub
64114
runs-on: ubuntu-latest
65115
defaults:
@@ -73,14 +123,14 @@ jobs:
73123
id: meta
74124
uses: docker/metadata-action@v5
75125
with:
76-
images: eclipsebasyx/basyx-python-server
126+
images: eclipsebasyx/basyx-python-discovery
77127
# This fetches the latest git tag and adds an additional "latest" to it, so e.g. `2.1.0,latest`
78128
tags: |
79129
type=semver,pattern={{version}}
80130
type=raw,value=latest
81131
labels: |
82-
org.opencontainers.image.title=BaSyx Python Server
83-
org.opencontainers.image.description=Eclipse BaSyx Python SDK - HTTP Server
132+
org.opencontainers.image.title=BaSyx Python Discovery Service
133+
org.opencontainers.image.description=Eclipse BaSyx Python SDK - Discovery HTTP Server
84134
org.opencontainers.image.source=https://github.com/eclipse-basyx/basyx-python-sdk/tree/main/server
85135
org.opencontainers.image.licenses=MIT
86136
@@ -90,11 +140,65 @@ jobs:
90140
username: ${{ secrets.DOCKER_HUB_USER }}
91141
password: ${{ secrets.DOCKER_HUB_TOKEN }}
92142

93-
- name: Build and Push Docker Image
143+
- name: Setup QEMU
144+
uses: docker/setup-qemu-action@v4
145+
146+
- name: Set up Docker Buildx
147+
uses: docker/setup-buildx-action@v4
148+
149+
- name: Build and Push Repository Docker Image
150+
uses: docker/build-push-action@v6
151+
with:
152+
context: .
153+
file: ./server/docker/discovery/Dockerfile
154+
platforms: ${{ env.TARGET_PLATFORM }}
155+
push: true
156+
tags: ${{ steps.meta.outputs.tags }}
157+
labels: ${{ steps.meta.outputs.labels }}
158+
159+
server-registry-publish:
160+
# This job publishes the server docker image to DockerHub
161+
runs-on: ubuntu-latest
162+
defaults:
163+
run:
164+
working-directory: ./server
165+
steps:
166+
- name: Checkout Repository
167+
uses: actions/checkout@v4
168+
169+
- name: Extract Docker image metadata
170+
id: meta
171+
uses: docker/metadata-action@v5
172+
with:
173+
images: eclipsebasyx/basyx-python-registry
174+
# This fetches the latest git tag and adds an additional "latest" to it, so e.g. `2.1.0,latest`
175+
tags: |
176+
type=semver,pattern={{version}}
177+
type=raw,value=latest
178+
labels: |
179+
org.opencontainers.image.title=BaSyx Python Registry Service
180+
org.opencontainers.image.description=Eclipse BaSyx Python SDK - Registry HTTP Server
181+
org.opencontainers.image.source=https://github.com/eclipse-basyx/basyx-python-sdk/tree/main/server
182+
org.opencontainers.image.licenses=MIT
183+
184+
- name: Log in to Docker Hub
185+
uses: docker/login-action@v4
186+
with:
187+
username: ${{ secrets.DOCKER_HUB_USER }}
188+
password: ${{ secrets.DOCKER_HUB_TOKEN }}
189+
190+
- name: Setup QEMU
191+
uses: docker/setup-qemu-action@v4
192+
193+
- name: Set up Docker Buildx
194+
uses: docker/setup-buildx-action@v4
195+
196+
- name: Build and Push Repository Docker Image
94197
uses: docker/build-push-action@v6
95198
with:
96199
context: .
97-
file: ./server/Dockerfile # Todo: Update paths
200+
file: ./server/docker/registry/Dockerfile
201+
platforms: ${{ env.TARGET_PLATFORM }}
98202
push: true
99203
tags: ${{ steps.meta.outputs.tags }}
100204
labels: ${{ steps.meta.outputs.labels }}

sdk/basyx/aas/backend/couchdb.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -160,22 +160,17 @@ def get_identifiable_by_couchdb_id(self, couchdb_id: str) -> model.Identifiable:
160160
raise KeyError("No Identifiable with couchdb-id {} found in CouchDB database".format(couchdb_id)) from e
161161
raise
162162

163-
# Add CouchDB metadata (for later commits) to object
164163
obj = data['data']
165164
if not isinstance(obj, model.Identifiable):
166165
raise CouchDBResponseError("The CouchDB document with id {} does not contain an identifiable AAS object."
167166
.format(couchdb_id))
168167
set_couchdb_revision("{}/{}/{}".format(self.url, self.database_name, urllib.parse.quote(couchdb_id, safe='')),
169168
data["_rev"])
170169

171-
# If we still have a local replication of that object (since it is referenced from anywhere else), update that
172-
# replication and return it.
173170
with self._object_cache_lock:
174171
if obj.id in self._object_cache:
175-
old_obj = self._object_cache[obj.id]
176-
old_obj.update_from(obj)
177-
return old_obj
178-
self._object_cache[obj.id] = obj
172+
return self._object_cache[obj.id]
173+
self._object_cache[obj.id] = obj
179174
return obj
180175

181176
def get_item(self, identifier: model.Identifier) -> model.Identifiable:
@@ -186,6 +181,9 @@ def get_item(self, identifier: model.Identifier) -> model.Identifiable:
186181
:raises CouchDBError: If error occur during the request to the CouchDB server
187182
(see ``_do_request()`` for details)
188183
"""
184+
with self._object_cache_lock:
185+
if identifier in self._object_cache:
186+
return self._object_cache[identifier]
189187
try:
190188
return self.get_identifiable_by_couchdb_id(self._transform_id(identifier, False))
191189
except KeyError as e:
@@ -220,6 +218,37 @@ def add(self, x: model.Identifiable) -> None:
220218
with self._object_cache_lock:
221219
self._object_cache[x.id] = x
222220

221+
def commit(self, x: model.Identifiable) -> None:
222+
"""
223+
Write the current in-memory state of a stored object back to the CouchDB.
224+
225+
:param x: The object to persist
226+
:raises KeyError: If the object is not present in the store or no revision is known
227+
:raises CouchDBConflictError: If the object was modified in the database since it was last fetched
228+
:raises CouchDBError: If error occur during the request to the CouchDB server
229+
(see ``_do_request()`` for details)
230+
"""
231+
doc_url = "{}/{}/{}".format(self.url, self.database_name, self._transform_id(x.id))
232+
rev = get_couchdb_revision(doc_url)
233+
if rev is None:
234+
raise KeyError("No revision found for object with id {} — not fetched from this store".format(x.id))
235+
data = json.dumps({'data': x}, cls=json_serialization.AASToJsonEncoder)
236+
try:
237+
response = self._do_request(
238+
"{}?rev={}".format(doc_url, rev),
239+
'PUT',
240+
{'Content-type': 'application/json'},
241+
data.encode('utf-8'))
242+
set_couchdb_revision(doc_url, response["rev"])
243+
except CouchDBServerError as e:
244+
if e.code == 404:
245+
raise KeyError("No AAS object with id {} exists in CouchDB database".format(x.id)) from e
246+
elif e.code == 409:
247+
raise CouchDBConflictError(
248+
"Object with id {} has been modified in the database since it was last fetched."
249+
.format(x.id)) from e
250+
raise
251+
223252
def discard(self, x: model.Identifiable, safe_delete=False) -> None:
224253
"""
225254
Delete an :class:`~basyx.aas.model.base.Identifiable` AAS object from the CouchDB database

sdk/basyx/aas/backend/local_file.py

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import json
1717
import os
1818
import hashlib
19+
import tempfile
1920
import threading
2021
import warnings
2122
import weakref
@@ -31,6 +32,13 @@ class LocalFileIdentifiableStore(model.AbstractObjectStore[model.Identifier, mod
3132
"""
3233
An ObjectStore implementation for :class:`~basyx.aas.model.base.Identifiable` BaSyx Python SDK objects backed
3334
by a local file based local backend
35+
36+
.. warning::
37+
This backend is intended for development and testing only. It provides no
38+
concurrency control across processes: concurrent writes to the same object
39+
(e.g. under a multi-worker WSGI server) will silently overwrite each other,
40+
with the last writer winning and no error raised. Use a dedicated database
41+
backend for any production deployment.
3442
"""
3543
def __init__(self, directory_path: str):
3644
"""
@@ -68,21 +76,16 @@ def get_identifiable_by_hash(self, hash_: str) -> model.Identifiable:
6876
6977
:raises KeyError: If the respective file could not be found
7078
"""
71-
# Try to get the correct file
7279
try:
7380
with open("{}/{}.json".format(self.directory_path, hash_), "r") as file:
7481
data = json.load(file, cls=json_deserialization.AASFromJsonDecoder)
7582
obj = data["data"]
7683
except FileNotFoundError as e:
7784
raise KeyError("No Identifiable with hash {} found in local file database".format(hash_)) from e
78-
# If we still have a local replication of that object (since it is referenced from anywhere else), update that
79-
# replication and return it.
8085
with self._object_cache_lock:
8186
if obj.id in self._object_cache:
82-
old_obj = self._object_cache[obj.id]
83-
old_obj.update_from(obj)
84-
return old_obj
85-
self._object_cache[obj.id] = obj
87+
return self._object_cache[obj.id]
88+
self._object_cache[obj.id] = obj
8689
return obj
8790

8891
def get_item(self, identifier: model.Identifier) -> model.Identifiable:
@@ -91,11 +94,33 @@ def get_item(self, identifier: model.Identifier) -> model.Identifiable:
9194
9295
:raises KeyError: If the respective file could not be found
9396
"""
97+
with self._object_cache_lock:
98+
if identifier in self._object_cache:
99+
return self._object_cache[identifier]
94100
try:
95101
return self.get_identifiable_by_hash(self._transform_id(identifier))
96102
except KeyError as e:
97103
raise KeyError("No Identifiable with id {} found in local file database".format(identifier)) from e
98104

105+
def _write_atomic(self, x: model.Identifiable) -> None:
106+
"""
107+
Serialize x to a temp file in the store directory, then atomically replace the final file.
108+
109+
Using os.replace() (rename(2) on POSIX) ensures readers always see a complete file — never
110+
a partially-written one from a crash or concurrent access mid-write.
111+
"""
112+
final_path = "{}/{}.json".format(self.directory_path, self._transform_id(x.id))
113+
tmp_fd, tmp_path = tempfile.mkstemp(dir=self.directory_path, suffix=".tmp")
114+
try:
115+
with os.fdopen(tmp_fd, "w") as tmp_file:
116+
json.dump({"data": x}, tmp_file, cls=json_serialization.AASToJsonEncoder, indent=4)
117+
os.replace(tmp_path, final_path)
118+
# Catch all `Exception`s, as well as `KeyboardInterrupt` and `SystemExit` too, so the temp
119+
# file is never left behind even if the process is being torn down:
120+
except BaseException:
121+
os.unlink(tmp_path)
122+
raise
123+
99124
def add(self, x: model.Identifiable) -> None:
100125
"""
101126
Add an object to the store
@@ -105,10 +130,20 @@ def add(self, x: model.Identifiable) -> None:
105130
logger.debug("Adding object %s to Local File Store ...", repr(x))
106131
if os.path.exists("{}/{}.json".format(self.directory_path, self._transform_id(x.id))):
107132
raise KeyError("Identifiable with id {} already exists in local file database".format(x.id))
108-
with open("{}/{}.json".format(self.directory_path, self._transform_id(x.id)), "w") as file:
109-
json.dump({"data": x}, file, cls=json_serialization.AASToJsonEncoder, indent=4)
110-
with self._object_cache_lock:
111-
self._object_cache[x.id] = x
133+
self._write_atomic(x)
134+
with self._object_cache_lock:
135+
self._object_cache[x.id] = x
136+
137+
def commit(self, x: model.Identifiable) -> None:
138+
"""
139+
Write the current in-memory state of a stored object back to its file.
140+
141+
:param x: The object to persist
142+
:raises KeyError: If the object is not present in the store
143+
"""
144+
if not os.path.exists("{}/{}.json".format(self.directory_path, self._transform_id(x.id))):
145+
raise KeyError("No AAS object with id {} exists in local file database".format(x.id))
146+
self._write_atomic(x)
112147

113148
def discard(self, x: model.Identifiable) -> None:
114149
"""

sdk/basyx/aas/model/provider.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,18 @@ class AbstractObjectStore(AbstractObjectProvider[_KEY, _VALUE], MutableSet[_VALU
5959
def __init__(self):
6060
pass
6161

62+
def commit(self, x: _VALUE) -> None:
63+
"""
64+
Persist an in-memory mutation of a stored object back to the underlying storage.
65+
66+
Persistent backends (e.g. file-based or database-backed stores) must override this to
67+
write the updated object back to storage. In-memory stores should override this with an
68+
explicit no-op to make the intent clear.
69+
70+
:param x: The object whose current in-memory state should be persisted
71+
"""
72+
raise NotImplementedError()
73+
6274
def update(self, other: Iterable[_VALUE]) -> None:
6375
for x in other:
6476
self.add(x)
@@ -146,6 +158,9 @@ def add(self, x: _IDENTIFIABLE) -> None:
146158
.format(x.id))
147159
self._backend[x.id] = x
148160

161+
def commit(self, x: _IDENTIFIABLE) -> None:
162+
pass
163+
149164
def discard(self, x: _IDENTIFIABLE) -> None:
150165
if self._backend.get(x.id) is x:
151166
del self._backend[x.id]
@@ -223,6 +238,9 @@ def add(self, x: _IDENTIFIABLE) -> None:
223238
else:
224239
raise KeyError(f"Identifiable object with same id {x.id} is already stored in this store")
225240

241+
def commit(self, x: _IDENTIFIABLE) -> None:
242+
pass
243+
226244
def discard(self, x: _IDENTIFIABLE) -> None:
227245
self._backend.discard(x)
228246

0 commit comments

Comments
 (0)