Skip to content

Commit 7438abf

Browse files
authored
Extract server apps and funcs from sdk into server (#388)
This separates server functionalities (Repository apps, in the future also Discovery, and Registries) from the core SDK to improve modularity and maintainability. It also includes some refactoring to avoid code duplication in future. Changes: - Moved all server-related classes and functions from `sdk` to `server` - Splitted `http.py` into `repository.py`, `base.py` and `util/converters.py` - Created parent classes `BaseWSGIApp` and `ObjectStoreWSGIApp` for repository app class, which will also be used for discovery and registry apps in the future - Added an initial pyproject.toml for the server app - Refactored `object_hook()` by extracting the mapping dict into `_get_aas_class_parsers()` - Refactored `default()` by extracting the mapping dict into `_get_aas_class_serializers()` - Refactored `_create_dict()` - Created a JSON_AAS_TOP_LEVEL_KEYS_TO_TYPES tuple with top-level JSON keys and the corresponding SDK types to reuse it in JSON de-/serialization methods
1 parent 8800bff commit 7438abf

File tree

20 files changed

+757
-565
lines changed

20 files changed

+757
-565
lines changed

.github/workflows/ci.yml

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -254,13 +254,13 @@ jobs:
254254
pip install .[dev]
255255
- name: Check typing with MyPy
256256
run: |
257-
mypy ./aas_compliance_tool test
257+
mypy aas_compliance_tool test
258258
- name: Check code style with PyCodestyle
259259
run: |
260-
pycodestyle --count --max-line-length 120 ./aas_compliance_tool test
260+
pycodestyle --count --max-line-length 120 aas_compliance_tool test
261261
262-
compliance-tool-readme-codeblocks:
263-
# This job runs the same static code analysis (mypy and pycodestyle) on the codeblocks in our docstrings.
262+
compliance-tool-package:
263+
# This job checks if we can build our compliance_tool package
264264
runs-on: ubuntu-latest
265265

266266
defaults:
@@ -272,42 +272,43 @@ jobs:
272272
uses: actions/setup-python@v5
273273
with:
274274
python-version: ${{ env.X_PYTHON_MIN_VERSION }}
275-
- name: Install Python dependencies
276-
# install the local sdk in editable mode so it does not get overwritten
275+
- name: Install dependencies
277276
run: |
278277
python -m pip install --upgrade pip
279-
pip install -e ../sdk[dev]
280-
pip install .[dev]
281-
- name: Check typing with MyPy
282-
run: |
283-
mypy <(codeblocks python README.md)
284-
- name: Check code style with PyCodestyle
285-
run: |
286-
codeblocks --wrap python README.md | pycodestyle --count --max-line-length 120 -
287-
- name: Run readme codeblocks with Python
278+
pip install build
279+
- name: Create source and wheel dist
288280
run: |
289-
codeblocks python README.md | python
281+
python -m build
290282
291-
compliance-tool-package:
292-
# This job checks if we can build our compliance_tool package
283+
#server-test:
284+
# TODO: This job runs the unittests on the python versions specified down at the matrix
285+
# and aas-test-engines on the server
286+
287+
288+
server-static-analysis:
289+
# This job runs static code analysis, namely pycodestyle and mypy
293290
runs-on: ubuntu-latest
294291

295292
defaults:
296293
run:
297-
working-directory: ./compliance_tool
294+
working-directory: ./server/app
298295
steps:
299296
- uses: actions/checkout@v4
300297
- name: Set up Python ${{ env.X_PYTHON_MIN_VERSION }}
301298
uses: actions/setup-python@v5
302299
with:
303300
python-version: ${{ env.X_PYTHON_MIN_VERSION }}
304-
- name: Install dependencies
301+
- name: Install Python dependencies
305302
run: |
306303
python -m pip install --upgrade pip
307-
pip install build
308-
- name: Create source and wheel dist
304+
pip install ../../sdk
305+
pip install .[dev]
306+
- name: Check typing with MyPy
309307
run: |
310-
python -m build
308+
mypy .
309+
- name: Check code style with PyCodestyle
310+
run: |
311+
pycodestyle --count --max-line-length 120 .
311312
312313
server-package:
313314
# This job checks if we can build our server package

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ sdk/test/adapter/schemas
2929
# Ignore dynamically generated version file
3030
sdk/basyx/version.py
3131
compliance_tool/aas_compliance_tool/version.py
32+
server/app/version.py
3233

3334
# ignore the content of the server storage
3435
server/storage/

sdk/README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,7 @@ The BaSyx Python SDK requires the following Python packages to be installed for
4242
* `lxml` (BSD 3-clause License, using `libxml2` under MIT License)
4343
* `python-dateutil` (BSD 3-clause License)
4444
* `pyecma376-2` (Apache License v2.0)
45-
* `urllib3` (MIT License)
46-
* `Werkzeug` (BSD 3-clause License)
45+
4746

4847
Development/testing/documentation/example dependencies:
4948
* `mypy` (MIT License)

sdk/basyx/aas/adapter/_generic.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@
1919
PathOrBinaryIO = Union[Path, BinaryIO]
2020
PathOrIO = Union[Path, IO] # IO is TextIO or BinaryIO
2121

22+
# JSON top-level keys and their corresponding model classes
23+
JSON_AAS_TOP_LEVEL_KEYS_TO_TYPES = (
24+
('assetAdministrationShells', model.AssetAdministrationShell),
25+
('submodels', model.Submodel),
26+
('conceptDescriptions', model.ConceptDescription),
27+
)
28+
2229
# XML Namespace definition
2330
XML_NS_MAP = {"aas": "https://admin-shell.io/aas/3/0"}
2431
XML_NS_AAS = "{" + XML_NS_MAP["aas"] + "}"

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

Lines changed: 46 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,13 @@
3434
import json
3535
import logging
3636
import pprint
37-
from typing import Dict, Callable, ContextManager, TypeVar, Type, List, IO, Optional, Set, get_args
37+
from typing import (Dict, Callable, ContextManager, TypeVar, Type,
38+
List, IO, Optional, Set, get_args, Tuple, Iterable, Any)
3839

3940
from basyx.aas import model
4041
from .._generic import MODELLING_KIND_INVERSE, ASSET_KIND_INVERSE, KEY_TYPES_INVERSE, ENTITY_TYPES_INVERSE, \
4142
IEC61360_DATA_TYPES_INVERSE, IEC61360_LEVEL_TYPES_INVERSE, KEY_TYPES_CLASSES_INVERSE, REFERENCE_TYPES_INVERSE, \
42-
DIRECTION_INVERSE, STATE_OF_EVENT_INVERSE, QUALIFIER_KIND_INVERSE, PathOrIO, Path
43+
DIRECTION_INVERSE, STATE_OF_EVENT_INVERSE, QUALIFIER_KIND_INVERSE, PathOrIO, Path, JSON_AAS_TOP_LEVEL_KEYS_TO_TYPES
4344

4445
logger = logging.getLogger(__name__)
4546

@@ -154,19 +155,20 @@ def __init__(self, *args, **kwargs):
154155
json.JSONDecoder.__init__(self, object_hook=self.object_hook, *args, **kwargs)
155156

156157
@classmethod
157-
def object_hook(cls, dct: Dict[str, object]) -> object:
158-
# Check if JSON object seems to be a deserializable AAS object (i.e. it has a modelType). Otherwise, the JSON
159-
# object is returned as is, so it's possible to mix AAS objects with other data within a JSON structure.
160-
if 'modelType' not in dct:
161-
return dct
158+
def _get_aas_class_parsers(cls) -> Dict[str, Callable[[Dict[str, object]], object]]:
159+
"""
160+
Returns the dictionary of AAS class parsers.
161+
162+
The following dict specifies a constructor method for all AAS classes that may be identified using the
163+
``modelType`` attribute in their JSON representation. Each of those constructor functions takes the JSON
164+
representation of an object and tries to construct a Python object from it. Embedded objects that have a
165+
modelType themselves are expected to be converted to the correct PythonType already. Additionally, each
166+
function takes a bool parameter ``failsafe``, which indicates weather to log errors and skip defective objects
167+
instead of raising an Exception.
162168
163-
# The following dict specifies a constructor method for all AAS classes that may be identified using the
164-
# ``modelType`` attribute in their JSON representation. Each of those constructor functions takes the JSON
165-
# representation of an object and tries to construct a Python object from it. Embedded objects that have a
166-
# modelType themselves are expected to be converted to the correct PythonType already. Additionally, each
167-
# function takes a bool parameter ``failsafe``, which indicates weather to log errors and skip defective objects
168-
# instead of raising an Exception.
169-
AAS_CLASS_PARSERS: Dict[str, Callable[[Dict[str, object]], object]] = {
169+
:return: The dictionary of AAS class parsers
170+
"""
171+
aas_class_parsers: Dict[str, Callable[[Dict[str, object]], object]] = {
170172
'AssetAdministrationShell': cls._construct_asset_administration_shell,
171173
'AssetInformation': cls._construct_asset_information,
172174
'SpecificAssetId': cls._construct_specific_asset_id,
@@ -189,6 +191,16 @@ def object_hook(cls, dct: Dict[str, object]) -> object:
189191
'ReferenceElement': cls._construct_reference_element,
190192
'DataSpecificationIec61360': cls._construct_data_specification_iec61360,
191193
}
194+
return aas_class_parsers
195+
196+
@classmethod
197+
def object_hook(cls, dct: Dict[str, object]) -> object:
198+
# Check if JSON object seems to be a deserializable AAS object (i.e. it has a modelType). Otherwise, the JSON
199+
# object is returned as is, so it's possible to mix AAS objects with other data within a JSON structure.
200+
if 'modelType' not in dct:
201+
return dct
202+
203+
AAS_CLASS_PARSERS = cls._get_aas_class_parsers()
192204

193205
# Get modelType and constructor function
194206
if not isinstance(dct['modelType'], str):
@@ -799,7 +811,9 @@ def _select_decoder(failsafe: bool, stripped: bool, decoder: Optional[Type[AASFr
799811

800812
def read_aas_json_file_into(object_store: model.AbstractObjectStore, file: PathOrIO, replace_existing: bool = False,
801813
ignore_existing: bool = False, failsafe: bool = True, stripped: bool = False,
802-
decoder: Optional[Type[AASFromJsonDecoder]] = None) -> Set[model.Identifier]:
814+
decoder: Optional[Type[AASFromJsonDecoder]] = None,
815+
keys_to_types: Iterable[Tuple[str, Any]] = JSON_AAS_TOP_LEVEL_KEYS_TO_TYPES) \
816+
-> Set[model.Identifier]:
803817
"""
804818
Read an Asset Administration Shell JSON file according to 'Details of the Asset Administration Shell', chapter 5.5
805819
into a given object store.
@@ -817,6 +831,7 @@ def read_aas_json_file_into(object_store: model.AbstractObjectStore, file: PathO
817831
See https://git.rwth-aachen.de/acplt/pyi40aas/-/issues/91
818832
This parameter is ignored if a decoder class is specified.
819833
:param decoder: The decoder class used to decode the JSON objects
834+
:param keys_to_types: A dictionary of JSON keys to expected types. This is used to check the type of the objects
820835
:raises KeyError: **Non-failsafe**: Encountered a duplicate identifier
821836
:raises KeyError: Encountered an identifier that already exists in the given ``object_store`` with both
822837
``replace_existing`` and ``ignore_existing`` set to ``False``
@@ -843,45 +858,43 @@ def read_aas_json_file_into(object_store: model.AbstractObjectStore, file: PathO
843858
with cm as fp:
844859
data = json.load(fp, cls=decoder_)
845860

846-
for name, expected_type in (('assetAdministrationShells', model.AssetAdministrationShell),
847-
('submodels', model.Submodel),
848-
('conceptDescriptions', model.ConceptDescription)):
861+
for name, expected_type in keys_to_types:
849862
try:
850863
lst = _get_ts(data, name, list)
851864
except (KeyError, TypeError):
852865
continue
853866

854867
for item in lst:
855-
error_message = "Expected a {} in list '{}', but found {}".format(
856-
expected_type.__name__, name, repr(item))
868+
error_msg = f"Expected a {expected_type.__name__} in list '{name}', but found {repr(item)}."
857869
if isinstance(item, model.Identifiable):
858870
if not isinstance(item, expected_type):
859-
if decoder_.failsafe:
860-
logger.warning("{} was in wrong list '{}'; nevertheless, we'll use it".format(item, name))
861-
else:
862-
raise TypeError(error_message)
871+
if not decoder_.failsafe:
872+
raise TypeError(f"{item} was in the wrong list '{name}'")
873+
logger.warning(f"{item} was in the wrong list '{name}'; nevertheless, we'll use it")
874+
863875
if item.id in ret:
864-
error_message = f"{item} has a duplicate identifier already parsed in the document!"
876+
error_msg = f"{item} has a duplicate identifier already parsed in the document!"
865877
if not decoder_.failsafe:
866-
raise KeyError(error_message)
867-
logger.error(error_message + " skipping it...")
878+
raise KeyError(error_msg)
879+
logger.error(f"{error_msg} Skipping it...")
868880
continue
881+
869882
existing_element = object_store.get(item.id)
870883
if existing_element is not None:
871884
if not replace_existing:
872-
error_message = f"object with identifier {item.id} already exists " \
873-
f"in the object store: {existing_element}!"
885+
error_msg = f"Object with id '{item.id}' already exists in store: {existing_element}!"
874886
if not ignore_existing:
875-
raise KeyError(error_message + f" failed to insert {item}!")
876-
logger.info(error_message + f" skipping insertion of {item}...")
887+
raise KeyError(f"{error_msg} Failed to insert {item}!")
888+
logger.info(f"{error_msg} Skipping {item}...")
877889
continue
878890
object_store.discard(existing_element)
891+
879892
object_store.add(item)
880893
ret.add(item.id)
881894
elif decoder_.failsafe:
882-
logger.error(error_message)
895+
logger.error(f"{error_msg} Skipping it...")
883896
else:
884-
raise TypeError(error_message)
897+
raise TypeError(error_msg)
885898
return ret
886899

887900

0 commit comments

Comments
 (0)