Skip to content

Commit

Permalink
adapter.{json,xml}: make (de-)serialization interfaces coherent
Browse files Browse the repository at this point in the history
lxml supports paths already, no modification is necessary there.
However, the `lxml.etree.ElementTree.write()` function requires
`BinaryIO`, i.e. files opened with the 'b' mode. While it would be
possible to access the underlying binary buffer of files opened in text
mode via `open()`, this isn't possible for `io.StringIO()`, as it
doesn't have the `buffer` property. Thus, even if we could support files
opened via `open()` in text mode, we couldn't annotate the XML
serialization functions with `TextIO`, as `io.StringIO()` remains
unsupported. Because of that, I decided to not support `TextIO` for the
XML serialization.

The builtin JSON module only supports file handles, with the
`json.dump()` method only supporting `TextIO` and `json.load()`
supporting `TextIO` and `BinaryIO`. Thus, the JSON adapter is modified
to `open()` given paths, while the JSON serialization is additionally
modified to wrap `BinaryIO` with `io.TextIOWrapper`.

Fix #42
  • Loading branch information
jkhsjdhjs authored and s-heppner committed Mar 14, 2024
1 parent 7f9a7a9 commit 4ff3db8
Show file tree
Hide file tree
Showing 5 changed files with 76 additions and 20 deletions.
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
24 changes: 18 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 @@ -819,8 +820,19 @@ def read_aas_json_file_into(object_store: model.AbstractObjectStore, file: IO, r
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,7 +876,7 @@ 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
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)
12 changes: 6 additions & 6 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,7 +1186,7 @@ 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
Expand Down Expand Up @@ -1289,7 +1289,7 @@ 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
Expand Down Expand Up @@ -1397,7 +1397,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 Down Expand Up @@ -1470,7 +1470,7 @@ 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
Expand Down
17 changes: 14 additions & 3 deletions basyx/aas/adapter/xml/xml_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,21 @@
- For serializing any object to an XML fragment, that fits the XML specification from 'Details of the
Asset Administration Shell', chapter 5.4, check out ``<class_name>_to_xml()``. These functions return
an :class:`~lxml.etree.Element` object to be serialized into XML.
.. attention::
Unlike the XML deserialization and the JSON (de-)serialization, the XML serialization only supports
:class:`~typing.BinaryIO` and not :class:`~typing.TextIO`. Thus, if you open files by yourself, you have to open
them in binary mode, see the mode table of :func:`open`.
.. code:: python
# wb = open for writing + binary mode
with open("example.xml", "wb") as fp:
write_aas_xml_file(fp, object_store)
"""

from lxml import etree # type: ignore
from typing import Dict, IO, Optional, Type
from typing import Dict, Optional, Type
import base64

from basyx.aas import model
Expand Down Expand Up @@ -840,14 +851,14 @@ def basic_event_element_to_xml(obj: model.BasicEventElement, tag: str = NS_AAS+"
# ##############################################################


def write_aas_xml_file(file: IO,
def write_aas_xml_file(file: _generic.PathOrBinaryIO,
data: model.AbstractObjectStore,
**kwargs) -> None:
"""
Write a set of AAS objects to an Asset Administration Shell XML file according to 'Details of the Asset
Administration Shell', chapter 5.4
:param file: A file-like object to write the XML-serialized data to
:param file: A filename or file-like object to write the XML-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 an XML file
:param kwargs: Additional keyword arguments to be passed to :meth:`~lxml.etree.ElementTree.write`
Expand Down

0 comments on commit 4ff3db8

Please sign in to comment.