diff --git a/README.md b/README.md index 04f18a92d..fb6dd73e2 100644 --- a/README.md +++ b/README.md @@ -131,8 +131,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) ``` diff --git a/basyx/aas/adapter/_generic.py b/basyx/aas/adapter/_generic.py index 34c3412b1..3ca90cc28 100644 --- a/basyx/aas/adapter/_generic.py +++ b/basyx/aas/adapter/_generic.py @@ -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"] + "}" diff --git a/basyx/aas/adapter/aasx.py b/basyx/aas/adapter/aasx.py index 1c50feb3e..20eb41da7 100644 --- a/basyx/aas/adapter/aasx.py +++ b/basyx/aas/adapter/aasx.py @@ -285,9 +285,11 @@ class AASXWriter: file_store) writer.write_core_properties(cp) - **Attention:** The AASXWriter must always be closed using the :meth:`~.AASXWriter.close` method or its context - manager functionality (as shown above). Otherwise the resulting AASX file will lack important data structures - and will not be readable. + .. attention:: + + The AASXWriter must always be closed using the :meth:`~.AASXWriter.close` method or its context manager + functionality (as shown above). Otherwise, the resulting AASX file will lack important data structures + and will not be readable. """ AASX_ORIGIN_PART_NAME = "/aasx/aasx-origin" diff --git a/basyx/aas/adapter/json/json_deserialization.py b/basyx/aas/adapter/json/json_deserialization.py index 1e3aecbd1..f6c7c4183 100644 --- a/basyx/aas/adapter/json/json_deserialization.py +++ b/basyx/aas/adapter/json/json_deserialization.py @@ -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__) @@ -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]: """ @@ -803,7 +804,7 @@ def read_aas_json_file_into(object_store: model.AbstractObjectStore, file: IO, r :param object_store: The :class:`ObjectStore ` 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``. @@ -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 ` 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), @@ -864,7 +883,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 @@ -872,6 +891,11 @@ def read_aas_json_file(file: IO, **kwargs) -> model.DictObjectStore[model.Identi :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() diff --git a/basyx/aas/adapter/json/json_serialization.py b/basyx/aas/adapter/json/json_serialization.py index 2faadb82b..5891f1be3 100644 --- a/basyx/aas/adapter/json/json_serialization.py +++ b/basyx/aas/adapter/json/json_serialization.py @@ -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 @@ -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 ` 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. @@ -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) diff --git a/basyx/aas/adapter/xml/__init__.py b/basyx/aas/adapter/xml/__init__.py index 714c80663..af58ad0be 100644 --- a/basyx/aas/adapter/xml/__init__.py +++ b/basyx/aas/adapter/xml/__init__.py @@ -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 diff --git a/basyx/aas/adapter/xml/xml_deserialization.py b/basyx/aas/adapter/xml/xml_deserialization.py index 31b5012fb..1a6a3cabd 100644 --- a/basyx/aas/adapter/xml/xml_deserialization.py +++ b/basyx/aas/adapter/xml/xml_deserialization.py @@ -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"]} @@ -315,8 +315,8 @@ def _failsafe_construct_mandatory(element: etree.Element, constructor: Callable[ """ constructed = _failsafe_construct(element, constructor, False, **kwargs) if constructed is None: - raise TypeError("The result of a non-failsafe _failsafe_construct() call was None! " - "This is a bug in the Eclipse BaSyx Python SDK XML deserialization, please report it!") + raise AssertionError("The result of a non-failsafe _failsafe_construct() call was None! " + "This is a bug in the Eclipse BaSyx Python SDK XML deserialization, please report it!") return constructed @@ -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 @@ -1194,6 +1194,8 @@ def _parse_xml_document(file: IO, failsafe: bool = True, **parser_kwargs: Any) - :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 """ @@ -1249,7 +1251,7 @@ class XMLConstructables(enum.Enum): KEY = enum.auto() REFERENCE = enum.auto() MODEL_REFERENCE = enum.auto() - GLOBAL_REFERENCE = enum.auto() + EXTERNAL_REFERENCE = enum.auto() ADMINISTRATIVE_INFORMATION = enum.auto() QUALIFIER = enum.auto() SECURITY = enum.auto() @@ -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. @@ -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) @@ -1316,7 +1322,7 @@ def read_aas_xml_element(file: IO, construct: XMLConstructables, failsafe: bool constructor = decoder_.construct_reference elif construct == XMLConstructables.MODEL_REFERENCE: constructor = decoder_.construct_model_reference - elif construct == XMLConstructables.GLOBAL_REFERENCE: + elif construct == XMLConstructables.EXTERNAL_REFERENCE: constructor = decoder_.construct_external_reference elif construct == XMLConstructables.ADMINISTRATIVE_INFORMATION: constructor = decoder_.construct_administrative_information @@ -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]: @@ -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. ````) :return: A set of :class:`Identifiers ` that were added to object_store """ ret: Set[model.Identifier] = set() @@ -1470,7 +1484,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 @@ -1478,6 +1492,12 @@ def read_aas_xml_file(file: IO, **kwargs: Any) -> model.DictObjectStore[model.Id :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. ````) :return: A :class:`~basyx.aas.model.provider.DictObjectStore` containing all AAS objects from the XML file """ object_store: model.DictObjectStore[model.Identifiable] = model.DictObjectStore() diff --git a/basyx/aas/adapter/xml/xml_serialization.py b/basyx/aas/adapter/xml/xml_serialization.py index c6eb2be1c..cd02ead14 100644 --- a/basyx/aas/adapter/xml/xml_serialization.py +++ b/basyx/aas/adapter/xml/xml_serialization.py @@ -14,12 +14,25 @@ - For generating an XML-File from a :class:`~basyx.aas.model.provider.AbstractObjectStore`, check out the function :func:`write_aas_xml_file`. - 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 ``_to_xml()``. These functions return - an :class:`~lxml.etree.Element` object to be serialized into XML. + Asset Administration Shell', chapter 5.4, you can either use :func:`object_to_xml_element`, which serializes a given + object and returns it as :class:`~lxml.etree.Element`, **or** :func:`write_aas_xml_element`, which does the same + thing, but writes the :class:`~lxml.etree.Element` to a file instead of returning it. + As a third alternative, you can also use the functions ``_to_xml()`` directly. + +.. 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 Callable, Dict, Optional, Type import base64 from basyx.aas import model @@ -220,6 +233,20 @@ def data_element_to_xml(obj: model.DataElement) -> etree.Element: return reference_element_to_xml(obj) +def key_to_xml(obj: model.Key, tag: str = NS_AAS+"key") -> etree.Element: + """ + Serialization of objects of class :class:`~basyx.aas.model.base.Key` to XML + + :param obj: Object of class :class:`~basyx.aas.model.base.Key` + :param tag: Namespace+Tag of the returned element. Default is ``aas:key`` + :return: Serialized :class:`~lxml.etree.Element` object + """ + et_key = _generate_element(tag) + et_key.append(_generate_element(name=NS_AAS + "type", text=_generic.KEY_TYPES[obj.type])) + et_key.append(_generate_element(name=NS_AAS + "value", text=obj.value)) + return et_key + + def reference_to_xml(obj: model.Reference, tag: str = NS_AAS+"reference") -> etree.Element: """ Serialization of objects of class :class:`~basyx.aas.model.base.Reference` to XML @@ -234,10 +261,7 @@ def reference_to_xml(obj: model.Reference, tag: str = NS_AAS+"reference") -> etr et_reference.append(reference_to_xml(obj.referred_semantic_id, NS_AAS + "referredSemanticId")) et_keys = _generate_element(name=NS_AAS + "keys") for aas_key in obj.key: - et_key = _generate_element(name=NS_AAS + "key") - et_key.append(_generate_element(name=NS_AAS + "type", text=_generic.KEY_TYPES[aas_key.type])) - et_key.append(_generate_element(name=NS_AAS + "value", text=aas_key.value)) - et_keys.append(et_key) + et_keys.append(key_to_xml(aas_key)) et_reference.append(et_keys) return et_reference @@ -839,18 +863,114 @@ def basic_event_element_to_xml(obj: model.BasicEventElement, tag: str = NS_AAS+" # general functions # ############################################################## +def _write_element(file: _generic.PathOrBinaryIO, element: etree.Element, **kwargs) -> None: + etree.ElementTree(element).write(file, encoding="UTF-8", xml_declaration=True, method="xml", **kwargs) + + +def object_to_xml_element(obj: object) -> etree.Element: + """ + Serialize a single object to an :class:`~lxml.etree.Element`. + + :param obj: The object to serialize + """ + serialization_func: Callable[..., etree.Element] + + if isinstance(obj, model.Key): + serialization_func = key_to_xml + elif isinstance(obj, model.Reference): + serialization_func = reference_to_xml + elif isinstance(obj, model.Reference): + serialization_func = reference_to_xml + elif isinstance(obj, model.AdministrativeInformation): + serialization_func = administrative_information_to_xml + elif isinstance(obj, model.Qualifier): + serialization_func = qualifier_to_xml + elif isinstance(obj, model.AnnotatedRelationshipElement): + serialization_func = annotated_relationship_element_to_xml + elif isinstance(obj, model.BasicEventElement): + serialization_func = basic_event_element_to_xml + elif isinstance(obj, model.Blob): + serialization_func = blob_to_xml + elif isinstance(obj, model.Capability): + serialization_func = capability_to_xml + elif isinstance(obj, model.Entity): + serialization_func = entity_to_xml + elif isinstance(obj, model.Extension): + serialization_func = extension_to_xml + elif isinstance(obj, model.File): + serialization_func = file_to_xml + elif isinstance(obj, model.Resource): + serialization_func = resource_to_xml + elif isinstance(obj, model.MultiLanguageProperty): + serialization_func = multi_language_property_to_xml + elif isinstance(obj, model.Operation): + serialization_func = operation_to_xml + elif isinstance(obj, model.Property): + serialization_func = property_to_xml + elif isinstance(obj, model.Range): + serialization_func = range_to_xml + elif isinstance(obj, model.ReferenceElement): + serialization_func = reference_element_to_xml + elif isinstance(obj, model.RelationshipElement): + serialization_func = relationship_element_to_xml + elif isinstance(obj, model.SubmodelElementCollection): + serialization_func = submodel_element_collection_to_xml + elif isinstance(obj, model.SubmodelElementList): + serialization_func = submodel_element_list_to_xml + elif isinstance(obj, model.AssetAdministrationShell): + serialization_func = asset_administration_shell_to_xml + elif isinstance(obj, model.AssetInformation): + serialization_func = asset_information_to_xml + elif isinstance(obj, model.SpecificAssetId): + serialization_func = specific_asset_id_to_xml + elif isinstance(obj, model.Submodel): + serialization_func = submodel_to_xml + elif isinstance(obj, model.ValueReferencePair): + serialization_func = value_reference_pair_to_xml + elif isinstance(obj, model.ConceptDescription): + serialization_func = concept_description_to_xml + elif isinstance(obj, model.LangStringSet): + serialization_func = lang_string_set_to_xml + elif isinstance(obj, model.EmbeddedDataSpecification): + serialization_func = embedded_data_specification_to_xml + elif isinstance(obj, model.DataSpecificationIEC61360): + serialization_func = data_specification_iec61360_to_xml + # generic serialization using the functions for abstract classes + elif isinstance(obj, model.DataElement): + serialization_func = data_element_to_xml + elif isinstance(obj, model.SubmodelElement): + serialization_func = submodel_to_xml + elif isinstance(obj, model.DataSpecificationContent): + serialization_func = data_specification_content_to_xml + # type aliases + elif isinstance(obj, model.ValueList): + serialization_func = value_list_to_xml + else: + raise ValueError(f"{obj!r} cannot be serialized!") -def write_aas_xml_file(file: IO, - data: model.AbstractObjectStore, - **kwargs) -> None: + return serialization_func(obj) + + +def write_aas_xml_element(file: _generic.PathOrBinaryIO, obj: object, **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 + Serialize a single object to XML. Namespace declarations are added to the object itself, as there is no surrounding + environment element. + + :param file: A filename or file-like object to write the XML-serialized data to + :param obj: The object to serialize + :param kwargs: Additional keyword arguments to be passed to :meth:`~lxml.etree.ElementTree.write` + """ + return _write_element(file, object_to_xml_element(obj), **kwargs) + + +def object_store_to_xml_element(data: model.AbstractObjectStore) -> etree.Element: + """ + Serialize a set of AAS objects to an Asset Administration Shell as :class:`~lxml.etree.Element`. + This function is used internally by :meth:`write_aas_xml_file` and shouldn't be + called directly for most use-cases. - :param file: A file-like object to write the XML-serialized data to :param data: :class:`ObjectStore ` 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` """ # separate different kind of objects asset_administration_shells = [] @@ -882,5 +1002,19 @@ def write_aas_xml_file(file: IO, et_concept_descriptions.append(concept_description_to_xml(con_obj)) root.append(et_concept_descriptions) - tree = etree.ElementTree(root) - tree.write(file, encoding="UTF-8", xml_declaration=True, method="xml", **kwargs) + return root + + +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 filename or file-like object to write the XML-serialized data to + :param data: :class:`ObjectStore ` 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` + """ + return _write_element(file, object_store_to_xml_element(data), **kwargs) diff --git a/basyx/aas/examples/tutorial_serialization_deserialization.py b/basyx/aas/examples/tutorial_serialization_deserialization.py index a163dd9f0..4200cfc11 100755 --- a/basyx/aas/examples/tutorial_serialization_deserialization.py +++ b/basyx/aas/examples/tutorial_serialization_deserialization.py @@ -108,18 +108,13 @@ aashell.update() # step 4.3: writing the contents of the ObjectStore to a JSON file -# Heads up! It is important to open the file in text-mode with utf-8 encoding! -with open('data.json', 'w', encoding='utf-8') as json_file: - basyx.aas.adapter.json.write_aas_json_file(json_file, obj_store) +basyx.aas.adapter.json.write_aas_json_file('data.json', obj_store) # We can pass the additional keyword argument `indent=4` to `write_aas_json_file()` to format the JSON file in a more # human-readable (but much more space-consuming) manner. # step 4.4: writing the contents of the ObjectStore to an XML file -# Heads up! For writing XML files -- in contrast to writing JSON --, the file must be opened in binary mode! The XML -# writer will handle character encoding internally. -with open('data.xml', 'wb') as xml_file: - basyx.aas.adapter.xml.write_aas_xml_file(xml_file, obj_store) +basyx.aas.adapter.xml.write_aas_xml_file('data.xml', obj_store) ################################################################## @@ -127,19 +122,13 @@ ################################################################## # step 5.1: reading contents of the JSON file as an ObjectStore -# Heads up! It is important to open the file in text-mode with utf-8 encoding! Using 'utf-8-sig' is recommended to -# handle unicode Byte Order Marks (BOM) correctly. -with open('data.json', encoding='utf-8-sig') as json_file: - json_file_data = basyx.aas.adapter.json.read_aas_json_file(json_file) +json_file_data = basyx.aas.adapter.json.read_aas_json_file('data.json') # By passing the `failsafe=False` argument to `read_aas_json_file()`, we can switch to the `StrictAASFromJsonDecoder` # (see step 3) for a stricter error reporting. # step 5.2: reading contents of the XML file as an ObjectStore -# Heads up! For reading XML files -- in contrast to reading JSON --, the file must be opened in binary mode! The XML -# writer will handle character encoding internally. -with open('data.xml', 'rb') as xml_file: - xml_file_data = basyx.aas.adapter.xml.read_aas_xml_file(xml_file) +xml_file_data = basyx.aas.adapter.xml.read_aas_xml_file('data.xml') # Again, we can use `failsafe=False` for switching on stricter error reporting in the parser. diff --git a/basyx/aas/model/base.py b/basyx/aas/model/base.py index 513c64758..332161dcb 100644 --- a/basyx/aas/model/base.py +++ b/basyx/aas/model/base.py @@ -503,7 +503,7 @@ def _get_object(self, object_type: Type[_NSO], attribute_name: str, attribute) - return ns_set.get_object_by_attribute(attribute_name, attribute) except KeyError: continue - raise KeyError(f"{object_type.__name__} with {attribute_name} {attribute} not found in this namespace") + raise KeyError(f"{object_type.__name__} with {attribute_name} {attribute} not found in {self!r}") def _add_object(self, attribute_name: str, obj: _NSO) -> None: """ @@ -531,7 +531,7 @@ def _remove_object(self, object_type: type, attribute_name: str, attribute) -> N return except KeyError: continue - raise KeyError(f"{object_type.__name__} with {attribute_name} {attribute} not found in this namespace") + raise KeyError(f"{object_type.__name__} with {attribute_name} {attribute} not found in {self!r}") class HasExtension(Namespace, metaclass=abc.ABCMeta): @@ -659,6 +659,32 @@ def _set_category(self, category: Optional[NameType]): def _get_category(self) -> Optional[NameType]: return self._category + @classmethod + def validate_id_short(cls, id_short: NameType) -> None: + """ + Validates an id_short against Constraint AASd-002 and :class:`NameType` restrictions. + + **Constraint AASd-002:** idShort of Referables shall only feature letters, digits, underscore (``_``); starting + mandatory with a letter. I.e. ``[a-zA-Z][a-zA-Z0-9_]+`` + + :param id_short: The id_short to validate + :raises ValueError: If the id_short doesn't comply to the constraints imposed by :class:`NameType` + (see :func:`~basyx.aas.model._string_constraints.check_name_type`). + :raises AASConstraintViolation: If the id_short doesn't comply to Constraint AASd-002. + """ + _string_constraints.check_name_type(id_short) + test_id_short: NameType = str(id_short) + if not re.fullmatch("[a-zA-Z0-9_]*", test_id_short): + raise AASConstraintViolation( + 2, + "The id_short must contain only letters, digits and underscore" + ) + if not test_id_short[0].isalpha(): + raise AASConstraintViolation( + 2, + "The id_short must start with a letter" + ) + category = property(_get_category, _set_category) def _set_id_short(self, id_short: Optional[NameType]): @@ -672,25 +698,16 @@ def _set_id_short(self, id_short: Optional[NameType]): (case-sensitive) :param id_short: Identifying string of the element within its name space - :raises ValueError: if the constraint is not fulfilled - :raises KeyError: if the new idShort causes a name collision in the parent Namespace + :raises ValueError: If the id_short doesn't comply to the constraints imposed by :class:`NameType` + (see :func:`~basyx.aas.model._string_constraints.check_name_type`). + :raises AASConstraintViolation: If the new idShort causes a name collision in the parent Namespace or if the + id_short doesn't comply to Constraint AASd-002. """ if id_short == self.id_short: return if id_short is not None: - _string_constraints.check_name_type(id_short) - test_id_short: NameType = str(id_short) - if not re.fullmatch("[a-zA-Z0-9_]*", test_id_short): - raise AASConstraintViolation( - 2, - "The id_short must contain only letters, digits and underscore" - ) - if not test_id_short[0].isalpha(): - raise AASConstraintViolation( - 2, - "The id_short must start with a letter" - ) + self.validate_id_short(id_short) if self.parent is not None: if id_short is None: @@ -849,7 +866,7 @@ def _direct_source_commit(self): class UnexpectedTypeError(TypeError): """ - Exception to be raised by :meth:`basyx.aas.model.base.ModelReference.resolve` if the retrieved object has not + Exception to be raised by :meth:`.ModelReference.resolve` if the retrieved object has not the expected type. :ivar value: The object of unexpected type @@ -1012,48 +1029,30 @@ def resolve(self, provider_: "provider.AbstractObjectProvider") -> _RT: :return: The referenced object (or a proxy object for it) :raises IndexError: If the list of keys is empty :raises TypeError: If one of the intermediate objects on the path is not a - :class:`~basyx.aas.model.base.Namespace` + :class:`~.UniqueIdShortNamespace` + :raises ValueError: If a non-numeric index is given to resolve in a + :class:`~basyx.aas.model.submodel.SubmodelElementList` :raises UnexpectedTypeError: If the retrieved object is not of the expected type (or one of its subclasses). The object is stored in the ``value`` attribute of the exception :raises KeyError: If the reference could not be resolved """ - from . import SubmodelElementList - # For ModelReferences, the first key must be an AasIdentifiable. So resolve the first key via the provider. identifier: Optional[Identifier] = self.key[0].get_identifier() if identifier is None: - raise AssertionError("Retrieving the identifier of the first key failed.") + raise AssertionError(f"Retrieving the identifier of the first {self.key[0]!r} failed.") - resolved_keys: List[str] = [] # for more helpful error messages try: item: Referable = provider_.get_identifiable(identifier) except KeyError as e: raise KeyError("Could not resolve identifier {}".format(identifier)) from e - resolved_keys.append(str(identifier)) - # All keys following the first must not reference identifiables (AASd-125). Thus, we can just follow the path - # recursively. - for key in self.key[1:]: - if not isinstance(item, UniqueIdShortNamespace): - raise TypeError("Object retrieved at {} is not a Namespace".format(" / ".join(resolved_keys))) - is_submodel_element_list = isinstance(item, SubmodelElementList) - try: - if is_submodel_element_list: - # The key's value must be numeric, since this is checked for keys following keys of type - # SUBMODEL_ELEMENT_LIST on construction of ModelReferences. - # Additionally item is known to be a SubmodelElementList which supports __getitem__ because we're in - # the `is_submodel_element_list` branch, but mypy doesn't infer types based on isinstance checks - # stored in boolean variables. - item = item.value[int(key.value)] # type: ignore - resolved_keys[-1] += f"[{key.value}]" - else: - item = item.get_referable(key.value) - resolved_keys.append(item.id_short) - except (KeyError, IndexError) as e: - raise KeyError("Could not resolve {} {} at {}".format( - "index" if is_submodel_element_list else "id_short", key.value, " / ".join(resolved_keys)))\ - from e + # All keys following the first must not reference identifiables (AASd-125). Thus, we can just resolve the + # id_short path via get_referable(). + # This is cursed af, but at least it keeps the code DRY. get_referable() will check the type of self in the + # first iteration, so we can ignore the type here. + item = UniqueIdShortNamespace.get_referable(item, # type: ignore[arg-type] + map(lambda k: k.value, self.key[1:])) # Check type if not isinstance(item, self.type): @@ -1717,15 +1716,45 @@ def __init__(self) -> None: super().__init__() self.namespace_element_sets: List[NamespaceSet] = [] - def get_referable(self, id_short: NameType) -> Referable: + def get_referable(self, id_short: Union[NameType, Iterable[NameType]]) -> Referable: """ - Find a :class:`~.Referable` in this Namespace by its id_short + Find a :class:`~.Referable` in this Namespace by its id_short or by its id_short path. + The id_short path may contain :class:`~basyx.aas.model.submodel.SubmodelElementList` indices. - :param id_short: id_short + :param id_short: id_short or id_short path as any :class:`Iterable` :returns: :class:`~.Referable` + :raises TypeError: If one of the intermediate objects on the path is not a + :class:`~.UniqueIdShortNamespace` + :raises ValueError: If a non-numeric index is given to resolve in a + :class:`~basyx.aas.model.submodel.SubmodelElementList` :raises KeyError: If no such :class:`~.Referable` can be found """ - return super()._get_object(Referable, "id_short", id_short) # type: ignore + from .submodel import SubmodelElementList + if isinstance(id_short, NameType): + id_short = [id_short] + item: Union[UniqueIdShortNamespace, Referable] = self + for id_ in id_short: + # This is redundant on first iteration, but it's a negligible overhead. + # Also, ModelReference.resolve() relies on this check. + if not isinstance(item, UniqueIdShortNamespace): + raise TypeError(f"Cannot resolve id_short or index '{id_}' at {item!r}, " + f"because it is not a {UniqueIdShortNamespace.__name__}!") + is_submodel_element_list = isinstance(item, SubmodelElementList) + try: + if is_submodel_element_list: + # item is known to be a SubmodelElementList which supports __getitem__ because we're in + # the `is_submodel_element_list` branch, but mypy doesn't infer types based on isinstance checks + # stored in boolean variables. + item = item.value[int(id_)] # type: ignore + else: + item = item._get_object(Referable, "id_short", id_) # type: ignore[type-abstract] + except ValueError as e: + raise ValueError(f"Cannot resolve '{id_}' at {item!r}, because it is not a numeric index!") from e + except (KeyError, IndexError) as e: + raise KeyError("Referable with {} {} not found in {}".format( + "index" if is_submodel_element_list else "id_short", id_, repr(item))) from e + # All UniqueIdShortNamespaces are Referables, and we only ever assign Referable to item. + return item # type: ignore[return-value] def add_referable(self, referable: Referable) -> None: """ diff --git a/test/adapter/json/test_json_serialization_deserialization.py b/test/adapter/json/test_json_serialization_deserialization.py index 2d64af353..351b71845 100644 --- a/test/adapter/json/test_json_serialization_deserialization.py +++ b/test/adapter/json/test_json_serialization_deserialization.py @@ -16,6 +16,8 @@ example_aas_mandatory_attributes, example_submodel_template, create_example from basyx.aas.examples.data._helper import AASDataChecker +from typing import Iterable, IO + class JsonSerializationDeserializationTest(unittest.TestCase): def test_random_object_serialization_deserialization(self) -> None: @@ -41,15 +43,17 @@ def test_random_object_serialization_deserialization(self) -> None: json_object_store = read_aas_json_file(io.StringIO(json_data), failsafe=False) def test_example_serialization_deserialization(self) -> None: - data = example_aas.create_full_example() - file = io.StringIO() - write_aas_json_file(file=file, data=data) - - # try deserializing the json string into a DictObjectStore of AAS objects with help of the json module - file.seek(0) - json_object_store = read_aas_json_file(file, failsafe=False) - checker = AASDataChecker(raise_immediately=True) - example_aas.check_full_example(checker, json_object_store) + # test with TextIO and BinaryIO, which should both be supported + t: Iterable[IO] = (io.StringIO(), io.BytesIO()) + for file in t: + data = example_aas.create_full_example() + write_aas_json_file(file=file, data=data) + + # try deserializing the json string into a DictObjectStore of AAS objects with help of the json module + file.seek(0) + json_object_store = read_aas_json_file(file, failsafe=False) + checker = AASDataChecker(raise_immediately=True) + example_aas.check_full_example(checker, json_object_store) class JsonSerializationDeserializationTest2(unittest.TestCase): diff --git a/test/adapter/xml/test_xml_deserialization.py b/test/adapter/xml/test_xml_deserialization.py index 4ff06aa60..dd0e48b85 100644 --- a/test/adapter/xml/test_xml_deserialization.py +++ b/test/adapter/xml/test_xml_deserialization.py @@ -18,10 +18,7 @@ def _xml_wrap(xml: str) -> str: - return \ - """""" \ - f""" """ \ - + xml + """""" + return f'{xml}' def _root_cause(exception: BaseException) -> BaseException: @@ -44,11 +41,11 @@ def _assertInExceptionAndLog(self, xml: str, strings: Union[Iterable[str], str], """ if isinstance(strings, str): strings = [strings] - bytes_io = io.BytesIO(xml.encode("utf-8")) + string_io = io.StringIO(xml) with self.assertLogs(logging.getLogger(), level=log_level) as log_ctx: - read_aas_xml_file(bytes_io, failsafe=True) + read_aas_xml_file(string_io, failsafe=True) with self.assertRaises(error_type) as err_ctx: - read_aas_xml_file(bytes_io, failsafe=False) + read_aas_xml_file(string_io, failsafe=False) cause = _root_cause(err_ctx.exception) for s in strings: self.assertIn(s, log_ctx.output[0]) @@ -142,7 +139,7 @@ def test_no_modelling_kind(self) -> None: """) # should get parsed successfully - object_store = read_aas_xml_file(io.BytesIO(xml.encode("utf-8")), failsafe=False) + object_store = read_aas_xml_file(io.StringIO(xml), failsafe=False) # modelling kind should default to INSTANCE submodel = object_store.pop() self.assertIsInstance(submodel, model.Submodel) @@ -171,7 +168,7 @@ def test_reference_kind_mismatch(self) -> None: """) with self.assertLogs(logging.getLogger(), level=logging.WARNING) as context: - read_aas_xml_file(io.BytesIO(xml.encode("utf-8")), failsafe=False) + read_aas_xml_file(io.StringIO(xml), failsafe=False) for s in ("SUBMODEL", "http://acplt.org/test_ref", "AssetAdministrationShell"): self.assertIn(s, context.output[0]) @@ -254,7 +251,7 @@ def test_operation_variable_too_many_submodel_elements(self) -> None: """) with self.assertLogs(logging.getLogger(), level=logging.WARNING) as context: - read_aas_xml_file(io.BytesIO(xml.encode("utf-8")), failsafe=False) + read_aas_xml_file(io.StringIO(xml), failsafe=False) self.assertIn("aas:value", context.output[0]) self.assertIn("more than one submodel element", context.output[0]) @@ -294,10 +291,10 @@ def get_clean_store() -> model.DictObjectStore: """) - bytes_io = io.BytesIO(xml.encode("utf-8")) + string_io = io.StringIO(xml) object_store = get_clean_store() - identifiers = read_aas_xml_file_into(object_store, bytes_io, replace_existing=True, ignore_existing=False) + identifiers = read_aas_xml_file_into(object_store, string_io, replace_existing=True, ignore_existing=False) self.assertEqual(identifiers.pop(), sm_id) submodel = object_store.pop() self.assertIsInstance(submodel, model.Submodel) @@ -305,7 +302,7 @@ def get_clean_store() -> model.DictObjectStore: object_store = get_clean_store() with self.assertLogs(logging.getLogger(), level=logging.INFO) as log_ctx: - identifiers = read_aas_xml_file_into(object_store, bytes_io, replace_existing=False, ignore_existing=True) + identifiers = read_aas_xml_file_into(object_store, string_io, replace_existing=False, ignore_existing=True) self.assertEqual(len(identifiers), 0) self.assertIn("already exists in the object store", log_ctx.output[0]) submodel = object_store.pop() @@ -314,7 +311,7 @@ def get_clean_store() -> model.DictObjectStore: object_store = get_clean_store() with self.assertRaises(KeyError) as err_ctx: - identifiers = read_aas_xml_file_into(object_store, bytes_io, replace_existing=False, ignore_existing=False) + identifiers = read_aas_xml_file_into(object_store, string_io, replace_existing=False, ignore_existing=False) self.assertEqual(len(identifiers), 0) cause = _root_cause(err_ctx.exception) self.assertIn("already exists in the object store", str(cause)) @@ -328,11 +325,27 @@ def test_read_aas_xml_element(self) -> None: http://acplt.org/test_submodel """ - bytes_io = io.BytesIO(xml.encode("utf-8")) + string_io = io.StringIO(xml) - submodel = read_aas_xml_element(bytes_io, XMLConstructables.SUBMODEL) + submodel = read_aas_xml_element(string_io, XMLConstructables.SUBMODEL) self.assertIsInstance(submodel, model.Submodel) + def test_no_namespace_prefix(self) -> None: + def xml(id_: str) -> str: + return f""" + + + + {id_} + + + + """ + + self._assertInExceptionAndLog(xml(""), f'{{{XML_NS_MAP["aas"]}}}id on line 5 has no text', KeyError, + logging.ERROR) + read_aas_xml_file(io.StringIO(xml("urn:x-test:test-submodel"))) + class XmlDeserializationStrippedObjectsTest(unittest.TestCase): def test_stripped_qualifiable(self) -> None: @@ -358,10 +371,10 @@ def test_stripped_qualifiable(self) -> None: """ - bytes_io = io.BytesIO(xml.encode("utf-8")) + string_io = io.StringIO(xml) # check if XML with qualifiers can be parsed successfully - submodel = read_aas_xml_element(bytes_io, XMLConstructables.SUBMODEL, failsafe=False) + submodel = read_aas_xml_element(string_io, XMLConstructables.SUBMODEL, failsafe=False) self.assertIsInstance(submodel, model.Submodel) assert isinstance(submodel, model.Submodel) self.assertEqual(len(submodel.qualifier), 1) @@ -369,7 +382,7 @@ def test_stripped_qualifiable(self) -> None: self.assertEqual(len(operation.qualifier), 1) # check if qualifiers are ignored in stripped mode - submodel = read_aas_xml_element(bytes_io, XMLConstructables.SUBMODEL, failsafe=False, stripped=True) + submodel = read_aas_xml_element(string_io, XMLConstructables.SUBMODEL, failsafe=False, stripped=True) self.assertIsInstance(submodel, model.Submodel) assert isinstance(submodel, model.Submodel) self.assertEqual(len(submodel.qualifier), 0) @@ -396,16 +409,16 @@ def test_stripped_asset_administration_shell(self) -> None: """ - bytes_io = io.BytesIO(xml.encode("utf-8")) + string_io = io.StringIO(xml) # check if XML with submodels can be parsed successfully - aas = read_aas_xml_element(bytes_io, XMLConstructables.ASSET_ADMINISTRATION_SHELL, failsafe=False) + aas = read_aas_xml_element(string_io, XMLConstructables.ASSET_ADMINISTRATION_SHELL, failsafe=False) self.assertIsInstance(aas, model.AssetAdministrationShell) assert isinstance(aas, model.AssetAdministrationShell) self.assertEqual(len(aas.submodel), 1) # check if submodels are ignored in stripped mode - aas = read_aas_xml_element(bytes_io, XMLConstructables.ASSET_ADMINISTRATION_SHELL, failsafe=False, + aas = read_aas_xml_element(string_io, XMLConstructables.ASSET_ADMINISTRATION_SHELL, failsafe=False, stripped=True) self.assertIsInstance(aas, model.AssetAdministrationShell) assert isinstance(aas, model.AssetAdministrationShell) @@ -430,9 +443,9 @@ def construct_submodel(cls, element: etree.Element, object_class=EnhancedSubmode http://acplt.org/test_stripped_submodel """ - bytes_io = io.BytesIO(xml.encode("utf-8")) + string_io = io.StringIO(xml) - submodel = read_aas_xml_element(bytes_io, XMLConstructables.SUBMODEL, decoder=EnhancedAASDecoder) + submodel = read_aas_xml_element(string_io, XMLConstructables.SUBMODEL, decoder=EnhancedAASDecoder) self.assertIsInstance(submodel, EnhancedSubmodel) assert isinstance(submodel, EnhancedSubmodel) self.assertEqual(submodel.enhanced_attribute, "fancy!") diff --git a/test/adapter/xml/test_xml_serialization_deserialization.py b/test/adapter/xml/test_xml_serialization_deserialization.py index c32653914..919092b2b 100644 --- a/test/adapter/xml/test_xml_serialization_deserialization.py +++ b/test/adapter/xml/test_xml_serialization_deserialization.py @@ -9,7 +9,8 @@ import unittest from basyx.aas import model -from basyx.aas.adapter.xml import write_aas_xml_file, read_aas_xml_file +from basyx.aas.adapter.xml import write_aas_xml_file, read_aas_xml_file, write_aas_xml_element, read_aas_xml_element, \ + XMLConstructables from basyx.aas.examples.data import example_aas_missing_attributes, example_aas, \ example_aas_mandatory_attributes, example_submodel_template, create_example @@ -53,3 +54,15 @@ def test_example_all_examples_serialization_deserialization(self) -> None: object_store = _serialize_and_deserialize(data) checker = AASDataChecker(raise_immediately=True) checker.check_object_store(object_store, data) + + +class XMLSerializationDeserializationSingleObjectTest(unittest.TestCase): + def test_submodel_serialization_deserialization(self) -> None: + submodel: model.Submodel = example_submodel_template.create_example_submodel_template() + bytes_io = io.BytesIO() + write_aas_xml_element(bytes_io, submodel) + bytes_io.seek(0) + submodel2: model.Submodel = read_aas_xml_element(bytes_io, # type: ignore[assignment] + XMLConstructables.SUBMODEL, failsafe=False) + checker = AASDataChecker(raise_immediately=True) + checker.check_submodel_equal(submodel2, submodel) diff --git a/test/model/test_base.py b/test/model/test_base.py index 44ac6861d..21ae1f13d 100644 --- a/test/model/test_base.py +++ b/test/model/test_base.py @@ -323,9 +323,11 @@ def test_update_commit_qualifier_extension_semantic_id(self): submodel.commit() -class ExampleNamespaceReferable(model.UniqueIdShortNamespace, model.UniqueSemanticIdNamespace): +class ExampleNamespaceReferable(model.UniqueIdShortNamespace, model.UniqueSemanticIdNamespace, model.Identifiable): def __init__(self, values=()): super().__init__() + # The 'id' is required by Referable.__repr__() in error messages. + self.id = self.__class__.__name__ self.set1 = model.NamespaceSet(self, [("id_short", False), ("semantic_id", True)]) self.set2 = model.NamespaceSet(self, [("id_short", False)], values) self.set3 = model.NamespaceSet(self, [("name", True)]) @@ -358,6 +360,9 @@ def setUp(self): self.prop7 = model.Property("Prop2", model.datatypes.Int, semantic_id=self.propSemanticID3) self.prop8 = model.Property("ProP2", model.datatypes.Int, semantic_id=self.propSemanticID3) self.prop1alt = model.Property("Prop1", model.datatypes.Int, semantic_id=self.propSemanticID) + self.collection1 = model.SubmodelElementCollection(None) + self.list1 = model.SubmodelElementList("List1", model.SubmodelElementCollection, + semantic_id=self.propSemanticID) self.qualifier1 = model.Qualifier("type1", model.datatypes.Int, 1, semantic_id=self.propSemanticID) self.qualifier2 = model.Qualifier("type2", model.datatypes.Int, 1, semantic_id=self.propSemanticID2) self.qualifier1alt = model.Qualifier("type1", model.datatypes.Int, 1, semantic_id=self.propSemanticID) @@ -572,17 +577,42 @@ def test_Namespace(self) -> None: self.assertIs(self.prop2, namespace.get_referable("Prop2")) with self.assertRaises(KeyError) as cm2: namespace.get_referable("Prop3") - self.assertEqual("'Referable with id_short Prop3 not found in this namespace'", - str(cm2.exception)) + self.assertEqual("'Referable with id_short Prop3 not found in " + f"{self._namespace_class.__name__}[{self.namespace.id}]'", str(cm2.exception)) namespace.remove_referable("Prop2") with self.assertRaises(KeyError) as cm3: namespace.get_referable("Prop2") - self.assertEqual("'Referable with id_short Prop2 not found in this namespace'", str(cm3.exception)) + self.assertEqual("'Referable with id_short Prop2 not found in " + f"{self._namespace_class.__name__}[{self.namespace.id}]'", str(cm3.exception)) with self.assertRaises(KeyError) as cm4: namespace.remove_referable("Prop2") - self.assertEqual("'Referable with id_short Prop2 not found in this namespace'", str(cm4.exception)) + self.assertEqual("'Referable with id_short Prop2 not found in " + f"{self._namespace_class.__name__}[{self.namespace.id}]'", str(cm4.exception)) + + def test_id_short_path_resolution(self) -> None: + self.namespace.set2.add(self.list1) + self.list1.add_referable(self.collection1) + self.collection1.add_referable(self.prop1) + + with self.assertRaises(ValueError) as cm: + self.namespace.get_referable(["List1", "a"]) + self.assertEqual(f"Cannot resolve 'a' at SubmodelElementList[{self.namespace.id} / List1], " + "because it is not a numeric index!", str(cm.exception)) + + with self.assertRaises(KeyError) as cm_2: + self.namespace.get_referable(["List1", "0", "Prop2"]) + self.assertEqual("'Referable with id_short Prop2 not found in " + f"SubmodelElementCollection[{self.namespace.id} / List1[0]]'", str(cm_2.exception)) + + with self.assertRaises(TypeError) as cm_3: + self.namespace.get_referable(["List1", "0", "Prop1", "Test"]) + self.assertEqual("Cannot resolve id_short or index 'Test' at " + f"Property[{self.namespace.id} / List1[0] / Prop1], " + "because it is not a UniqueIdShortNamespace!", str(cm_3.exception)) + + self.namespace.get_referable(["List1", "0", "Prop1"]) def test_renaming(self) -> None: self.namespace.set2.add(self.prop1) @@ -596,8 +626,8 @@ def test_renaming(self) -> None: self.assertIs(self.prop1, self.namespace.get_referable("Prop3")) with self.assertRaises(KeyError) as cm: self.namespace.get_referable('Prop1') - self.assertEqual("'Referable with id_short Prop1 not found in this namespace'", - str(cm.exception)) + self.assertEqual("'Referable with id_short Prop1 not found in " + f"{self._namespace_class.__name__}[{self.namespace.id}]'", str(cm.exception)) self.assertIs(self.prop2, self.namespace.get_referable("Prop2")) with self.assertRaises(model.AASConstraintViolation) as cm2: self.prop1.id_short = "Prop2" @@ -671,9 +701,11 @@ def test_aasd_117(self) -> None: property.id_short = "bar" -class ExampleOrderedNamespace(model.UniqueIdShortNamespace, model.UniqueSemanticIdNamespace): +class ExampleOrderedNamespace(model.UniqueIdShortNamespace, model.UniqueSemanticIdNamespace, model.Identifiable): def __init__(self, values=()): super().__init__() + # The 'id' is required by Referable.__repr__() in error messages. + self.id = self.__class__.__name__ self.set1 = model.OrderedNamespaceSet(self, [("id_short", False), ("semantic_id", True)]) self.set2 = model.OrderedNamespaceSet(self, [("id_short", False)], values) self.set3 = model.NamespaceSet(self, [("name", True)]) @@ -724,7 +756,8 @@ def test_OrderedNamespace(self) -> None: self.assertEqual(1, len(namespace2.set2)) with self.assertRaises(KeyError) as cm2: namespace2.get_referable("Prop1") - self.assertEqual("'Referable with id_short Prop1 not found in this namespace'", + self.assertEqual("'Referable with id_short Prop1 not found in " + f"{self._namespace_class.__name__}[{self.namespace.id}]'", # type: ignore[has-type] str(cm2.exception)) @@ -887,7 +920,7 @@ def get_identifiable(self, identifier: Identifier) -> Identifiable: model.Property) with self.assertRaises(KeyError) as cm: ref1.resolve(DummyObjectProvider()) - self.assertEqual("'Could not resolve id_short lst at urn:x-test:submodel'", str(cm.exception)) + self.assertEqual("'Referable with id_short lst not found in Submodel[urn:x-test:submodel]'", str(cm.exception)) ref2 = model.ModelReference((model.Key(model.KeyTypes.SUBMODEL, "urn:x-test:submodel"), model.Key(model.KeyTypes.SUBMODEL_ELEMENT_LIST, "list"), @@ -896,7 +929,8 @@ def get_identifiable(self, identifier: Identifier) -> Identifiable: model.Property) with self.assertRaises(KeyError) as cm_2: ref2.resolve(DummyObjectProvider()) - self.assertEqual("'Could not resolve index 99 at urn:x-test:submodel / list'", str(cm_2.exception)) + self.assertEqual("'Referable with index 99 not found in SubmodelElementList[urn:x-test:submodel / list]'", + str(cm_2.exception)) ref3 = model.ModelReference((model.Key(model.KeyTypes.SUBMODEL, "urn:x-test:submodel"), model.Key(model.KeyTypes.SUBMODEL_ELEMENT_LIST, "list"), @@ -913,8 +947,8 @@ def get_identifiable(self, identifier: Identifier) -> Identifiable: model.Property) with self.assertRaises(TypeError) as cm_3: ref4.resolve(DummyObjectProvider()) - self.assertEqual("Object retrieved at urn:x-test:submodel / list[0] / prop is not a Namespace", - str(cm_3.exception)) + self.assertEqual("Cannot resolve id_short or index 'prop' at Property[urn:x-test:submodel / list[0] / prop], " + "because it is not a UniqueIdShortNamespace!", str(cm_3.exception)) with self.assertRaises(AttributeError) as cm_4: ref1.key[2].value = "prop1" @@ -944,12 +978,18 @@ def get_identifiable(self, identifier: Identifier) -> Identifiable: with self.assertRaises(KeyError) as cm_8: ref8.resolve(DummyObjectProvider()) - self.assertEqual("'Could not resolve id_short prop_false at urn:x-test:submodel / list[0]'", - str(cm_8.exception)) + self.assertEqual("'Referable with id_short prop_false not found in " + "SubmodelElementCollection[urn:x-test:submodel / list[0]]'", str(cm_8.exception)) + + ref9 = model.ModelReference((model.Key(model.KeyTypes.SUBMODEL, "urn:x-test:submodel"), + model.Key(model.KeyTypes.SUBMODEL_ELEMENT_COLLECTION, "list"), + model.Key(model.KeyTypes.SUBMODEL_ELEMENT_COLLECTION, "collection")), + model.SubmodelElementCollection) with self.assertRaises(ValueError) as cm_9: - ref9 = model.ModelReference((), model.Submodel) - self.assertEqual('A reference must have at least one key!', str(cm_9.exception)) + ref9.resolve(DummyObjectProvider()) + self.assertEqual("Cannot resolve 'collection' at SubmodelElementList[urn:x-test:submodel / list], " + "because it is not a numeric index!", str(cm_9.exception)) def test_get_identifier(self) -> None: ref = model.ModelReference((model.Key(model.KeyTypes.SUBMODEL, "urn:x-test:x"),), model.Submodel)