Skip to content

Commit 5389ab4

Browse files
authored
Add DescriptorStores (#75)
* Add DescriptorStore * Fix unittests for LocalFileDescriptorStore
1 parent f885bce commit 5389ab4

7 files changed

Lines changed: 362 additions & 0 deletions

File tree

server/app/backend/__init__.py

Whitespace-only changes.

server/app/backend/local_file.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
from typing import Iterator, Dict, Type, Union
2+
import logging
3+
import json
4+
import os
5+
import hashlib
6+
import threading
7+
import weakref
8+
9+
from app.model import AssetAdministrationShellDescriptor, SubmodelDescriptor
10+
from basyx.aas import model
11+
from basyx.aas.model import provider as sdk_provider
12+
13+
from app.model.descriptor import Descriptor
14+
from app.adapter import jsonization
15+
16+
17+
logger = logging.getLogger(__name__)
18+
19+
20+
# We need to resolve the Descriptor type in order to deserialize it again from JSON
21+
DESCRIPTOR_TYPE_TO_STRING: Dict[Type[Union[AssetAdministrationShellDescriptor, SubmodelDescriptor]], str] = {
22+
AssetAdministrationShellDescriptor: "AssetAdministrationShellDescriptor",
23+
SubmodelDescriptor: "SubmodelDescriptor",
24+
}
25+
26+
27+
class LocalFileDescriptorStore(sdk_provider.AbstractObjectStore[model.Identifier, Descriptor]):
28+
"""
29+
An ObjectStore implementation for :class:`~app.model.descriptor.Descriptor` BaSyx Python SDK objects backed
30+
by a local file based local backend
31+
"""
32+
def __init__(self, directory_path: str):
33+
"""
34+
Initializer of class LocalFileDescriptorStore
35+
36+
:param directory_path: Path to the local file backend (the path where you want to store your AAS JSON files)
37+
"""
38+
self.directory_path: str = directory_path.rstrip("/")
39+
40+
# A dictionary of weak references to local replications of stored objects. Objects are kept in this cache as
41+
# long as there is any other reference in the Python application to them. We use this to make sure that only one
42+
# local replication of each object is kept in the application and retrieving an object from the store always
43+
# returns the **same** (not only equal) object. Still, objects are forgotten, when they are not referenced
44+
# anywhere else to save memory.
45+
self._object_cache: weakref.WeakValueDictionary[model.Identifier, Descriptor] \
46+
= weakref.WeakValueDictionary()
47+
self._object_cache_lock = threading.Lock()
48+
49+
def check_directory(self, create=False):
50+
"""
51+
Check if the directory exists and created it if not (and requested to do so)
52+
53+
:param create: If True and the database does not exist, try to create it
54+
"""
55+
if not os.path.exists(self.directory_path):
56+
if not create:
57+
raise FileNotFoundError("The given directory ({}) does not exist".format(self.directory_path))
58+
# Create directory
59+
os.mkdir(self.directory_path)
60+
logger.info("Creating directory {}".format(self.directory_path))
61+
62+
def get_descriptor_by_hash(self, hash_: str) -> Descriptor:
63+
"""
64+
Retrieve an AAS Descriptor object from the local file by its identifier hash
65+
66+
:raises KeyError: If the respective file could not be found
67+
"""
68+
# Try to get the correct file
69+
try:
70+
with open("{}/{}.json".format(self.directory_path, hash_), "r") as file:
71+
obj = json.load(file, cls=jsonization.ServerAASFromJsonDecoder)
72+
except FileNotFoundError as e:
73+
raise KeyError("No Descriptor with hash {} found in local file database".format(hash_)) from e
74+
# If we still have a local replication of that object (since it is referenced from anywhere else), update that
75+
# replication and return it.
76+
with self._object_cache_lock:
77+
if obj.id in self._object_cache:
78+
old_obj = self._object_cache[obj.id]
79+
old_obj.update_from(obj)
80+
return old_obj
81+
self._object_cache[obj.id] = obj
82+
return obj
83+
84+
def get_item(self, identifier: model.Identifier) -> Descriptor:
85+
"""
86+
Retrieve an AAS Descriptor object from the local file by its :class:`~basyx.aas.model.base.Identifier`
87+
88+
:raises KeyError: If the respective file could not be found
89+
"""
90+
try:
91+
return self.get_descriptor_by_hash(self._transform_id(identifier))
92+
except KeyError as e:
93+
raise KeyError("No Identifiable with id {} found in local file database".format(identifier)) from e
94+
95+
def add(self, x: Descriptor) -> None:
96+
"""
97+
Add a Descriptor object to the store
98+
99+
:raises KeyError: If an object with the same id exists already in the object store
100+
"""
101+
logger.debug("Adding object %s to Local File Store ...", repr(x))
102+
if os.path.exists("{}/{}.json".format(self.directory_path, self._transform_id(x.id))):
103+
raise KeyError("Descriptor with id {} already exists in local file database".format(x.id))
104+
with open("{}/{}.json".format(self.directory_path, self._transform_id(x.id)), "w") as file:
105+
# Usually, we don't need to serialize the modelType, since during HTTP requests, we know exactly if this
106+
# is an AASDescriptor or SubmodelDescriptor. However, here we cannot distinguish them, so to deserialize
107+
# them successfully, we hack the `modelType` into the JSON.
108+
serialized = json.loads(
109+
json.dumps(x, cls=jsonization.ServerAASToJsonEncoder)
110+
)
111+
serialized["modelType"] = DESCRIPTOR_TYPE_TO_STRING[type(x)]
112+
json.dump(serialized, file, indent=4)
113+
with self._object_cache_lock:
114+
self._object_cache[x.id] = x
115+
116+
def discard(self, x: Descriptor) -> None:
117+
"""
118+
Delete an :class:`~app.model.descriptor.Descriptor` AAS object from the local file store
119+
120+
:param x: The object to be deleted
121+
:raises KeyError: If the object does not exist in the database
122+
"""
123+
logger.debug("Deleting object %s from Local File Store database ...", repr(x))
124+
try:
125+
os.remove("{}/{}.json".format(self.directory_path, self._transform_id(x.id)))
126+
except FileNotFoundError as e:
127+
raise KeyError("No AAS Descriptor object with id {} exists in local file database".format(x.id)) from e
128+
with self._object_cache_lock:
129+
self._object_cache.pop(x.id, None)
130+
131+
def __contains__(self, x: object) -> bool:
132+
"""
133+
Check if an object with the given :class:`~basyx.aas.model.base.Identifier` or the same
134+
:class:`~basyx.aas.model.base.Identifier` as the given object is contained in the local file database
135+
136+
:param x: AAS object :class:`~basyx.aas.model.base.Identifier` or :class:`~app.model.descriptor.Descriptor`
137+
AAS object
138+
:return: ``True`` if such an object exists in the database, ``False`` otherwise
139+
"""
140+
if isinstance(x, model.Identifier):
141+
identifier = x
142+
elif isinstance(x, Descriptor):
143+
identifier = x.id
144+
else:
145+
return False
146+
logger.debug("Checking existence of Descriptor object with id %s in database ...", repr(x))
147+
return os.path.exists("{}/{}.json".format(self.directory_path, self._transform_id(identifier)))
148+
149+
def __len__(self) -> int:
150+
"""
151+
Retrieve the number of objects in the local file database
152+
153+
:return: The number of objects (determined from the number of documents)
154+
"""
155+
logger.debug("Fetching number of documents from database ...")
156+
return len(os.listdir(self.directory_path))
157+
158+
def __iter__(self) -> Iterator[Descriptor]:
159+
"""
160+
Iterate all :class:`~app.model.descriptor.Descriptor` objects in the local folder.
161+
162+
This method returns an iterator, containing only a list of all identifiers in the database and retrieving
163+
the identifiable objects on the fly.
164+
"""
165+
logger.debug("Iterating over objects in database ...")
166+
for name in os.listdir(self.directory_path):
167+
yield self.get_descriptor_by_hash(name.rstrip(".json"))
168+
169+
@staticmethod
170+
def _transform_id(identifier: model.Identifier) -> str:
171+
"""
172+
Helper method to represent an ASS Identifier as a string to be used as Local file document id
173+
"""
174+
return hashlib.sha256(identifier.encode("utf-8")).hexdigest()

server/app/model/provider.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from typing import Iterable, Dict, TypeVar, Iterator
2+
3+
from basyx.aas import model
4+
from basyx.aas.model import provider as sdk_provider
5+
6+
from app.model.descriptor import Descriptor
7+
8+
_DESCRIPTOR_TYPE = TypeVar("_DESCRIPTOR_TYPE", bound=Descriptor)
9+
10+
class DictDescriptorStore(sdk_provider.AbstractObjectStore[model.Identifier, _DESCRIPTOR_TYPE]):
11+
"""
12+
A local in-memory object store for :class:`~app.model.descriptor.Descriptor` objects, backed by a dict, mapping
13+
:class:`~basyx.aas.model.base.Identifier` → :class:`~app.model.descriptor.Descriptor`
14+
"""
15+
16+
def __init__(self, descriptors: Iterable[_DESCRIPTOR_TYPE] = ()) -> None:
17+
self._backend: Dict[model.Identifier, _DESCRIPTOR_TYPE] = {}
18+
for x in descriptors:
19+
self.add(x)
20+
21+
def get_item(self, identifier: model.Identifier) -> _DESCRIPTOR_TYPE:
22+
return self._backend[identifier]
23+
24+
def add(self, x: _DESCRIPTOR_TYPE) -> None:
25+
if x.id in self._backend and self._backend.get(x.id) is not x:
26+
raise KeyError("Descriptor object with same id {} is already stored in this store"
27+
.format(x.id))
28+
self._backend[x.id] = x
29+
30+
def discard(self, x: _DESCRIPTOR_TYPE) -> None:
31+
if self._backend.get(x.id) is x:
32+
del self._backend[x.id]
33+
34+
def __contains__(self, x: object) -> bool:
35+
if isinstance(x, model.Identifier):
36+
return x in self._backend
37+
if not isinstance(x, Descriptor):
38+
return False
39+
return self._backend.get(x.id) is x
40+
41+
def __len__(self) -> int:
42+
return len(self._backend)
43+
44+
def __iter__(self) -> Iterator[_DESCRIPTOR_TYPE]:
45+
return iter(self._backend.values())

server/test/backend/__init__.py

Whitespace-only changes.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Copyright (c) 2026 the Eclipse BaSyx Authors
2+
#
3+
# This program and the accompanying materials are made available under the terms of the MIT License, available in
4+
# the LICENSE file of this project.
5+
#
6+
# SPDX-License-Identifier: MIT
7+
import os.path
8+
import shutil
9+
10+
from unittest import TestCase
11+
12+
from app.backend import local_file
13+
from app import model
14+
from app.model import provider
15+
16+
store_path: str = os.path.dirname(__file__) + "/local_file_test_folder"
17+
source_core: str = "file://localhost/{}/".format(store_path)
18+
19+
20+
class LocalFileBackendTest(TestCase):
21+
def setUp(self) -> None:
22+
self.descriptor_store = local_file.LocalFileDescriptorStore(store_path)
23+
self.descriptor_store.check_directory(create=True)
24+
self.mock_endpoint = model.Endpoint(
25+
interface="AAS-3.0",
26+
protocol_information=model.ProtocolInformation(href="https://example.org/")
27+
)
28+
self.aasd1 = model.AssetAdministrationShellDescriptor(id_="https://example.org/AASDescriptor/1",
29+
endpoints=[self.mock_endpoint])
30+
self.aasd2 = model.AssetAdministrationShellDescriptor(id_="https://example.org/AASDescriptor/2",
31+
endpoints=[self.mock_endpoint])
32+
self.sd1 = model.SubmodelDescriptor(id_="https://example.org/SubmodelDescriptor/1",
33+
endpoints=[self.mock_endpoint])
34+
self.sd2 = model.SubmodelDescriptor(id_="https://example.org/SubmodelDescriptor/2",
35+
endpoints=[self.mock_endpoint])
36+
37+
def tearDown(self) -> None:
38+
try:
39+
self.descriptor_store.clear()
40+
finally:
41+
shutil.rmtree(store_path)
42+
43+
44+
def test_add(self) -> None:
45+
self.descriptor_store.add(self.aasd1)
46+
# Note that this test is only checking that there are no errors during adding.
47+
# The actual logic is tested together with retrieval in `test_retrieval`.
48+
49+
def test_retrieval(self) -> None:
50+
self.descriptor_store.add(self.sd1)
51+
52+
# When retrieving the object, we should get the *same* instance as we added
53+
retrieved_descriptor = self.descriptor_store.get_item("https://example.org/SubmodelDescriptor/1")
54+
self.assertIs(self.sd1, retrieved_descriptor)
55+
56+
def test_iterating(self) -> None:
57+
self.descriptor_store.add(self.sd1)
58+
self.descriptor_store.add(self.sd2)
59+
self.descriptor_store.add(self.aasd1)
60+
self.descriptor_store.add(self.aasd2)
61+
self.assertEqual(4, len(self.descriptor_store))
62+
63+
# Iterate objects, add them to a DictDescriptorStore and check them
64+
retrieved_descriptor_store = provider.DictDescriptorStore()
65+
for item in self.descriptor_store:
66+
retrieved_descriptor_store.add(item)
67+
self.assertEqual(4, len(retrieved_descriptor_store))
68+
self.assertIn(self.sd1, retrieved_descriptor_store)
69+
self.assertIn(self.sd2, retrieved_descriptor_store)
70+
self.assertIn(self.aasd1, retrieved_descriptor_store)
71+
self.assertIn(self.aasd2, retrieved_descriptor_store)
72+
73+
def test_key_errors(self) -> None:
74+
self.descriptor_store.add(self.aasd1)
75+
with self.assertRaises(KeyError) as cm:
76+
self.descriptor_store.add(self.aasd1)
77+
self.assertEqual("'Descriptor with id https://example.org/AASDescriptor/1 already exists in "
78+
"local file database'", str(cm.exception))
79+
80+
self.descriptor_store.discard(self.aasd1)
81+
with self.assertRaises(KeyError) as cm:
82+
self.descriptor_store.get_item("https://example.org/AASDescriptor/1")
83+
self.assertIsNone(self.descriptor_store.get("https://example.org/AASDescriptor/1"))
84+
self.assertEqual("'No Identifiable with id https://example.org/AASDescriptor/1 found in local "
85+
"file database'", str(cm.exception))
86+
87+
def test_reload_discard(self) -> None:
88+
self.descriptor_store.add(self.sd1)
89+
self.descriptor_store = local_file.LocalFileDescriptorStore(store_path)
90+
self.descriptor_store.discard(self.sd1)
91+
self.assertNotIn(self.sd1, self.descriptor_store)

server/test/model/__init__.py

Whitespace-only changes.

server/test/model/test_provider.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import unittest
2+
3+
from app import model
4+
from app.model.provider import DictDescriptorStore
5+
6+
class DictDescriptorStoreTest(unittest.TestCase):
7+
def setUp(self) -> None:
8+
self.mock_endpoint = model.Endpoint(
9+
interface="AAS-3.0",
10+
protocol_information=model.ProtocolInformation(href="https://example.org/")
11+
)
12+
self.aasd1 = model.AssetAdministrationShellDescriptor(id_="https://example.org/AASDescriptor/1",
13+
endpoints=[self.mock_endpoint])
14+
self.aasd2 = model.AssetAdministrationShellDescriptor(id_="https://example.org/AASDescriptor/2",
15+
endpoints=[self.mock_endpoint])
16+
self.sd1 = model.SubmodelDescriptor(id_="https://example.org/SubmodelDescriptor/1",
17+
endpoints=[self.mock_endpoint])
18+
self.sd2 = model.SubmodelDescriptor(id_="https://example.org/SubmodelDescriptor/2",
19+
endpoints=[self.mock_endpoint])
20+
21+
def test_store_retrieve(self) -> None:
22+
descriptor_store: DictDescriptorStore = DictDescriptorStore()
23+
descriptor_store.add(self.aasd1)
24+
descriptor_store.add(self.aasd2)
25+
self.assertIn(self.aasd1, descriptor_store)
26+
self.assertFalse(self.sd1 in descriptor_store)
27+
28+
aasd3 = model.AssetAdministrationShellDescriptor(id_="https://example.org/AASDescriptor/1",
29+
endpoints=[self.mock_endpoint])
30+
with self.assertRaises(KeyError) as cm:
31+
descriptor_store.add(aasd3)
32+
self.assertEqual("'Descriptor object with same id https://example.org/AASDescriptor/1 is already "
33+
"stored in this store'", str(cm.exception))
34+
self.assertEqual(2, len(descriptor_store))
35+
self.assertIs(self.aasd1, descriptor_store.get("https://example.org/AASDescriptor/1"))
36+
37+
descriptor_store.discard(self.aasd1)
38+
with self.assertRaises(KeyError) as cm:
39+
descriptor_store.get_item("https://example.org/AASDescriptor/1")
40+
self.assertIsNone(descriptor_store.get("https://example.org/AASDescriptor/1"))
41+
self.assertEqual("'https://example.org/AASDescriptor/1'", str(cm.exception))
42+
self.assertIs(self.aasd2, descriptor_store.pop())
43+
self.assertEqual(0, len(descriptor_store))
44+
45+
def test_store_update(self) -> None:
46+
descriptor_store1: DictDescriptorStore = DictDescriptorStore()
47+
descriptor_store2: DictDescriptorStore = DictDescriptorStore()
48+
descriptor_store1.add(self.sd1)
49+
descriptor_store2.add(self.sd2)
50+
descriptor_store1.update(descriptor_store2)
51+
self.assertIsInstance(descriptor_store1, DictDescriptorStore)
52+
self.assertIn(self.sd2, descriptor_store1)

0 commit comments

Comments
 (0)