Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

adapter.{json,xml}: make (de-)serialization interfaces coherent #251

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,7 @@ from basyx.aas.adapter.xml import write_aas_xml_file

data: model.DictObjectStore[model.Identifiable] = model.DictObjectStore()
data.add(submodel)
with open('Simple_Submodel.xml', 'wb') as f:
write_aas_xml_file(file=f, data=data)
write_aas_xml_file(file='Simple_Submodel.xml', data=data)
```


Expand Down
9 changes: 8 additions & 1 deletion basyx/aas/adapter/_generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,17 @@
The dicts defined in this module are used in the json and xml modules to translate enum members of our
implementation to the respective string and vice versa.
"""
from typing import Dict, Type
import os
from typing import BinaryIO, Dict, IO, Type, Union

from basyx.aas import model

# type aliases for path-like objects and IO
# used by write_aas_xml_file, read_aas_xml_file, write_aas_json_file, read_aas_json_file
Path = Union[str, bytes, os.PathLike]
PathOrBinaryIO = Union[Path, BinaryIO]
PathOrIO = Union[Path, IO] # IO is TextIO or BinaryIO

# XML Namespace definition
XML_NS_MAP = {"aas": "https://admin-shell.io/aas/3/0"}
XML_NS_AAS = "{" + XML_NS_MAP["aas"] + "}"
Expand Down
36 changes: 30 additions & 6 deletions basyx/aas/adapter/json/json_deserialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,16 @@
Other embedded objects are converted using a number of helper constructor methods.
"""
import base64
import contextlib
import json
import logging
import pprint
from typing import Dict, Callable, TypeVar, Type, List, IO, Optional, Set
from typing import Dict, Callable, ContextManager, TypeVar, Type, List, IO, Optional, Set, get_args

from basyx.aas import model
from .._generic import MODELLING_KIND_INVERSE, ASSET_KIND_INVERSE, KEY_TYPES_INVERSE, ENTITY_TYPES_INVERSE, \
IEC61360_DATA_TYPES_INVERSE, IEC61360_LEVEL_TYPES_INVERSE, KEY_TYPES_CLASSES_INVERSE, REFERENCE_TYPES_INVERSE, \
DIRECTION_INVERSE, STATE_OF_EVENT_INVERSE, QUALIFIER_KIND_INVERSE
DIRECTION_INVERSE, STATE_OF_EVENT_INVERSE, QUALIFIER_KIND_INVERSE, PathOrIO, Path

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -794,7 +795,7 @@ def _select_decoder(failsafe: bool, stripped: bool, decoder: Optional[Type[AASFr
return StrictAASFromJsonDecoder


def read_aas_json_file_into(object_store: model.AbstractObjectStore, file: IO, replace_existing: bool = False,
def read_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]:
"""
Expand All @@ -803,7 +804,7 @@ def read_aas_json_file_into(object_store: model.AbstractObjectStore, file: IO, r

:param object_store: The :class:`ObjectStore <basyx.aas.model.provider.AbstractObjectStore>` in which the
identifiable objects should be stored
:param file: A file-like object to read the JSON-serialized data from
:param file: A filename or file-like object to read the JSON-serialized data from
:param replace_existing: Whether to replace existing objects with the same identifier in the object store or not
:param ignore_existing: Whether to ignore existing objects (e.g. log a message) or raise an error.
This parameter is ignored if replace_existing is ``True``.
Expand All @@ -814,13 +815,31 @@ def read_aas_json_file_into(object_store: model.AbstractObjectStore, file: IO, r
See https://git.rwth-aachen.de/acplt/pyi40aas/-/issues/91
This parameter is ignored if a decoder class is specified.
:param decoder: The decoder class used to decode the JSON objects
:raises KeyError: **Non-failsafe**: Encountered a duplicate identifier
:raises KeyError: Encountered an identifier that already exists in the given ``object_store`` with both
``replace_existing`` and ``ignore_existing`` set to ``False``
:raises (~basyx.aas.model.base.AASConstraintViolation, KeyError, ValueError, TypeError): **Non-failsafe**:
Errors during construction of the objects
:raises TypeError: **Non-failsafe**: Encountered an element in the wrong list
(e.g. an AssetAdministrationShell in ``submodels``)
:return: A set of :class:`Identifiers <basyx.aas.model.base.Identifier>` that were added to object_store
"""
ret: Set[model.Identifier] = set()
decoder_ = _select_decoder(failsafe, stripped, decoder)

# json.load() accepts TextIO and BinaryIO
cm: ContextManager[IO]
if isinstance(file, get_args(Path)):
# 'file' is a path, needs to be opened first
cm = open(file, "r", encoding="utf-8-sig")
else:
# 'file' is not a path, thus it must already be IO
# mypy seems to have issues narrowing the type due to get_args()
cm = contextlib.nullcontext(file) # type: ignore[arg-type]

# read, parse and convert JSON file
data = json.load(file, cls=decoder_)
with cm as fp:
data = json.load(fp, cls=decoder_)

for name, expected_type in (('assetAdministrationShells', model.AssetAdministrationShell),
('submodels', model.Submodel),
Expand Down Expand Up @@ -864,14 +883,19 @@ def read_aas_json_file_into(object_store: model.AbstractObjectStore, file: IO, r
return ret


def read_aas_json_file(file: IO, **kwargs) -> model.DictObjectStore[model.Identifiable]:
def read_aas_json_file(file: PathOrIO, **kwargs) -> model.DictObjectStore[model.Identifiable]:
"""
A wrapper of :meth:`~basyx.aas.adapter.json.json_deserialization.read_aas_json_file_into`, that reads all objects
in an empty :class:`~basyx.aas.model.provider.DictObjectStore`. This function supports the same keyword arguments as
:meth:`~basyx.aas.adapter.json.json_deserialization.read_aas_json_file_into`.

:param file: A filename or file-like object to read the JSON-serialized data from
:param kwargs: Keyword arguments passed to :meth:`read_aas_json_file_into`
:raises KeyError: **Non-failsafe**: Encountered a duplicate identifier
:raises (~basyx.aas.model.base.AASConstraintViolation, KeyError, ValueError, TypeError): **Non-failsafe**:
Errors during construction of the objects
:raises TypeError: **Non-failsafe**: Encountered an element in the wrong list
(e.g. an AssetAdministrationShell in ``submodels``)
:return: A :class:`~basyx.aas.model.provider.DictObjectStore` containing all AAS objects from the JSON file
"""
object_store: model.DictObjectStore[model.Identifiable] = model.DictObjectStore()
Expand Down
34 changes: 30 additions & 4 deletions basyx/aas/adapter/json/json_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@
conversion functions to handle all the attributes of abstract base classes.
"""
import base64
import contextlib
import inspect
from typing import List, Dict, IO, Optional, Type, Callable
import io
from typing import ContextManager, List, Dict, Optional, TextIO, Type, Callable, get_args
import json

from basyx.aas import model
Expand Down Expand Up @@ -732,13 +734,21 @@ def object_store_to_json(data: model.AbstractObjectStore, stripped: bool = False
return json.dumps(_create_dict(data), cls=encoder_, **kwargs)


def write_aas_json_file(file: IO, data: model.AbstractObjectStore, stripped: bool = False,
class _DetachingTextIOWrapper(io.TextIOWrapper):
"""
Like :class:`io.TextIOWrapper`, but detaches on context exit instead of closing the wrapped buffer.
"""
def __exit__(self, exc_type, exc_val, exc_tb):
self.detach()


def write_aas_json_file(file: _generic.PathOrIO, data: model.AbstractObjectStore, stripped: bool = False,
encoder: Optional[Type[AASToJsonEncoder]] = None, **kwargs) -> None:
"""
Write a set of AAS objects to an Asset Administration Shell JSON file according to 'Details of the Asset
Administration Shell', chapter 5.5

:param file: A file-like object to write the JSON-serialized data to
:param file: A filename or file-like object to write the JSON-serialized data to
:param data: :class:`ObjectStore <basyx.aas.model.provider.AbstractObjectStore>` which contains different objects of
the AAS meta model which should be serialized to a JSON file
:param stripped: If `True`, objects are serialized to stripped json objects.
Expand All @@ -748,5 +758,21 @@ def write_aas_json_file(file: IO, data: model.AbstractObjectStore, stripped: boo
:param kwargs: Additional keyword arguments to be passed to `json.dump()`
"""
encoder_ = _select_encoder(stripped, encoder)

# json.dump() only accepts TextIO
cm: ContextManager[TextIO]
if isinstance(file, get_args(_generic.Path)):
# 'file' is a path, needs to be opened first
cm = open(file, "w", encoding="utf-8")
elif not hasattr(file, "encoding"):
# only TextIO has this attribute, so this must be BinaryIO, which needs to be wrapped
# mypy seems to have issues narrowing the type due to get_args()
cm = _DetachingTextIOWrapper(file, "utf-8", write_through=True) # type: ignore[arg-type]
else:
# we already got TextIO, nothing needs to be done
# mypy seems to have issues narrowing the type due to get_args()
cm = contextlib.nullcontext(file) # type: ignore[arg-type]

# serialize object to json
json.dump(_create_dict(data), file, cls=encoder_, **kwargs)
with cm as fp:
json.dump(_create_dict(data), fp, cls=encoder_, **kwargs)
3 changes: 2 additions & 1 deletion basyx/aas/adapter/xml/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"""
import os.path

from .xml_serialization import write_aas_xml_file
from .xml_serialization import object_store_to_xml_element, write_aas_xml_file, object_to_xml_element, \
write_aas_xml_element
from .xml_deserialization import AASFromXmlDecoder, StrictAASFromXmlDecoder, StrippedAASFromXmlDecoder, \
StrictStrippedAASFromXmlDecoder, XMLConstructables, read_aas_xml_file, read_aas_xml_file_into, read_aas_xml_element

Expand Down
34 changes: 27 additions & 7 deletions basyx/aas/adapter/xml/xml_deserialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@
import base64
import enum

from typing import Any, Callable, Dict, IO, Iterable, Optional, Set, Tuple, Type, TypeVar
from typing import Any, Callable, Dict, Iterable, Optional, Set, Tuple, Type, TypeVar
from .._generic import XML_NS_MAP, XML_NS_AAS, MODELLING_KIND_INVERSE, ASSET_KIND_INVERSE, KEY_TYPES_INVERSE, \
ENTITY_TYPES_INVERSE, IEC61360_DATA_TYPES_INVERSE, IEC61360_LEVEL_TYPES_INVERSE, KEY_TYPES_CLASSES_INVERSE, \
REFERENCE_TYPES_INVERSE, DIRECTION_INVERSE, STATE_OF_EVENT_INVERSE, QUALIFIER_KIND_INVERSE
REFERENCE_TYPES_INVERSE, DIRECTION_INVERSE, STATE_OF_EVENT_INVERSE, QUALIFIER_KIND_INVERSE, PathOrIO

NS_AAS = XML_NS_AAS
REQUIRED_NAMESPACES: Set[str] = {XML_NS_MAP["aas"]}
Expand Down Expand Up @@ -1186,14 +1186,16 @@ class StrictStrippedAASFromXmlDecoder(StrictAASFromXmlDecoder, StrippedAASFromXm
pass


def _parse_xml_document(file: IO, failsafe: bool = True, **parser_kwargs: Any) -> Optional[etree.Element]:
def _parse_xml_document(file: PathOrIO, failsafe: bool = True, **parser_kwargs: Any) -> Optional[etree.Element]:
"""
Parse an XML document into an element tree

:param file: A filename or file-like object to read the XML-serialized data from
:param failsafe: If True, the file is parsed in a failsafe way: Instead of raising an Exception if the document
is malformed, parsing is aborted, an error is logged and None is returned
:param parser_kwargs: Keyword arguments passed to the XMLParser constructor
:raises ~lxml.etree.XMLSyntaxError: **Non-failsafe**: If the given file(-handle) has invalid XML
:raises KeyError: **Non-failsafe**: If a required namespace has not been declared on the XML document
:return: The root element of the element tree
"""

Expand Down Expand Up @@ -1289,11 +1291,11 @@ class XMLConstructables(enum.Enum):
DATA_SPECIFICATION_IEC61360 = enum.auto()


def read_aas_xml_element(file: IO, construct: XMLConstructables, failsafe: bool = True, stripped: bool = False,
def read_aas_xml_element(file: PathOrIO, construct: XMLConstructables, failsafe: bool = True, stripped: bool = False,
decoder: Optional[Type[AASFromXmlDecoder]] = None, **constructor_kwargs) -> Optional[object]:
"""
Construct a single object from an XML string. The namespaces have to be declared on the object itself, since there
is no surrounding aasenv element.
is no surrounding environment element.

:param file: A filename or file-like object to read the XML-serialized data from
:param construct: A member of the enum :class:`~.XMLConstructables`, specifying which type to construct.
Expand All @@ -1305,6 +1307,10 @@ def read_aas_xml_element(file: IO, construct: XMLConstructables, failsafe: bool
This parameter is ignored if a decoder class is specified.
:param decoder: The decoder class used to decode the XML elements
:param constructor_kwargs: Keyword arguments passed to the constructor function
:raises ~lxml.etree.XMLSyntaxError: **Non-failsafe**: If the given file(-handle) has invalid XML
:raises KeyError: **Non-failsafe**: If a required namespace has not been declared on the XML document
:raises (~basyx.aas.model.base.AASConstraintViolation, KeyError, ValueError): **Non-failsafe**: Errors during
construction of the objects
:return: The constructed object or None, if an error occurred in failsafe mode.
"""
decoder_ = _select_decoder(failsafe, stripped, decoder)
Expand Down Expand Up @@ -1397,7 +1403,7 @@ def read_aas_xml_element(file: IO, construct: XMLConstructables, failsafe: bool
return _failsafe_construct(element, constructor, decoder_.failsafe, **constructor_kwargs)


def read_aas_xml_file_into(object_store: model.AbstractObjectStore[model.Identifiable], file: IO,
def read_aas_xml_file_into(object_store: model.AbstractObjectStore[model.Identifiable], file: PathOrIO,
replace_existing: bool = False, ignore_existing: bool = False, failsafe: bool = True,
stripped: bool = False, decoder: Optional[Type[AASFromXmlDecoder]] = None,
**parser_kwargs: Any) -> Set[model.Identifier]:
Expand All @@ -1419,6 +1425,14 @@ def read_aas_xml_file_into(object_store: model.AbstractObjectStore[model.Identif
This parameter is ignored if a decoder class is specified.
:param decoder: The decoder class used to decode the XML elements
:param parser_kwargs: Keyword arguments passed to the XMLParser constructor
:raises ~lxml.etree.XMLSyntaxError: **Non-failsafe**: If the given file(-handle) has invalid XML
:raises KeyError: **Non-failsafe**: If a required namespace has not been declared on the XML document
:raises KeyError: **Non-failsafe**: Encountered a duplicate identifier
:raises KeyError: Encountered an identifier that already exists in the given ``object_store`` with both
``replace_existing`` and ``ignore_existing`` set to ``False``
:raises (~basyx.aas.model.base.AASConstraintViolation, KeyError, ValueError): **Non-failsafe**: Errors during
construction of the objects
:raises TypeError: **Non-failsafe**: Encountered an undefined top-level list (e.g. ``<aas:submodels1>``)
:return: A set of :class:`Identifiers <basyx.aas.model.base.Identifier>` that were added to object_store
"""
ret: Set[model.Identifier] = set()
Expand Down Expand Up @@ -1470,14 +1484,20 @@ def read_aas_xml_file_into(object_store: model.AbstractObjectStore[model.Identif
return ret


def read_aas_xml_file(file: IO, **kwargs: Any) -> model.DictObjectStore[model.Identifiable]:
def read_aas_xml_file(file: PathOrIO, **kwargs: Any) -> model.DictObjectStore[model.Identifiable]:
"""
A wrapper of :meth:`~basyx.aas.adapter.xml.xml_deserialization.read_aas_xml_file_into`, that reads all objects in an
empty :class:`~basyx.aas.model.provider.DictObjectStore`. This function supports
the same keyword arguments as :meth:`~basyx.aas.adapter.xml.xml_deserialization.read_aas_xml_file_into`.

:param file: A filename or file-like object to read the XML-serialized data from
:param kwargs: Keyword arguments passed to :meth:`~basyx.aas.adapter.xml.xml_deserialization.read_aas_xml_file_into`
:raises ~lxml.etree.XMLSyntaxError: **Non-failsafe**: If the given file(-handle) has invalid XML
:raises KeyError: **Non-failsafe**: If a required namespace has not been declared on the XML document
:raises KeyError: **Non-failsafe**: Encountered a duplicate identifier
:raises (~basyx.aas.model.base.AASConstraintViolation, KeyError, ValueError): **Non-failsafe**: Errors during
construction of the objects
:raises TypeError: **Non-failsafe**: Encountered an undefined top-level list (e.g. ``<aas:submodels1>``)
:return: A :class:`~basyx.aas.model.provider.DictObjectStore` containing all AAS objects from the XML file
"""
object_store: model.DictObjectStore[model.Identifiable] = model.DictObjectStore()
Expand Down
Loading
Loading