diff --git a/sdk/basyx/aas/adapter/aasx.py b/sdk/basyx/aas/adapter/aasx.py index 0b66e533e..1b204b64c 100644 --- a/sdk/basyx/aas/adapter/aasx.py +++ b/sdk/basyx/aas/adapter/aasx.py @@ -28,7 +28,6 @@ import itertools import logging import os -import re from typing import Dict, Tuple, IO, Union, List, Set, Optional, Iterable, Iterator from .xml import read_aas_xml_file, write_aas_xml_file @@ -60,7 +59,7 @@ class AASXReader: reader.read_into(objects, files) """ - def __init__(self, file: Union[os.PathLike, str, IO]): + def __init__(self, file: Union[os.PathLike, str, IO], failsafe: bool = True): """ Open an AASX reader for the given filename or file handle @@ -69,16 +68,19 @@ def __init__(self, file: Union[os.PathLike, str, IO]): closing under any circumstances. :param file: A filename, file path or an open file-like object in binary mode + :param failsafe: If ``True``, the document is parsed in a failsafe way: Missing attributes and elements are + logged instead of causing exceptions. Defect objects are skipped. :raises FileNotFoundError: If the file does not exist :raises ValueError: If the file is not a valid OPC zip package """ + self.failsafe: bool = failsafe try: - logger.debug("Opening {} as AASX pacakge for reading ...".format(file)) + logger.debug(f"Opening {file} as AASX package for reading ...") self.reader = pyecma376_2.ZipPackageReader(file) except FileNotFoundError: raise except Exception as e: - raise ValueError("{} is not a valid ECMA376-2 (OPC) file: {}".format(file, e)) from e + raise ValueError(f"{file} is not a valid ECMA376-2 (OPC) file: {e}") from e def get_core_properties(self) -> pyecma376_2.OPCCoreProperties: """ @@ -132,7 +134,7 @@ def read_into(self, object_store: model.AbstractObjectStore, objects from the AASX file to :param file_store: A :class:`SupplementaryFileContainer <.AbstractSupplementaryFileContainer>` to add the embedded supplementary files to - :param override_existing: If ``True``, existing objects in the object store are overridden with objects from the + :param override_existing: If ``True``, existing objects in the ObjectStore are overridden with objects from the AASX that have the same :class:`~basyx.aas.model.base.Identifier`. Default behavior is to skip those objects from the AASX. :return: A set of the :class:`Identifiers ` of all @@ -159,7 +161,10 @@ def read_into(self, object_store: model.AbstractObjectStore, self._read_aas_part_into(split_part, object_store, file_store, read_identifiables, override_existing, **kwargs) if no_aas_files_found: - logger.warning("No AAS files found in AASX package") + if self.failsafe: + logger.warning("No AAS files found in AASX package") + else: + raise ValueError("No AAS files found in AASX package") return read_identifiables @@ -192,7 +197,7 @@ def _read_aas_part_into(self, part_name: str, from a File object of this part :param read_identifiables: A set of Identifiers of objects which have already been read. New objects' Identifiers are added to this set. Objects with already known Identifiers are skipped silently. - :param override_existing: If True, existing objects in the object store are overridden with objects from the + :param override_existing: If True, existing objects in the ObjectStore are overridden with objects from the AASX that have the same Identifier. Default behavior is to skip those objects from the AASX. """ for obj in self._parse_aas_part(part_name, **kwargs): @@ -200,12 +205,15 @@ def _read_aas_part_into(self, part_name: str, continue if obj.id in object_store: if override_existing: - logger.info("Overriding existing object in ObjectStore with {} ...".format(obj)) + logger.info(f"Overriding existing object in ObjectStore with {obj} ...") object_store.discard(obj) else: - logger.warning("Skipping {}, since an object with the same id is already contained in the " - "ObjectStore".format(obj)) - continue + if self.failsafe: + logger.warning(f"Skipping {obj}, since an object with the same id is already contained in the " + "ObjectStore") + continue + else: + raise ValueError(f"Object with id {obj} is already contained in the ObjectStore") object_store.add(obj) read_identifiables.add(obj.id) if isinstance(obj, model.Submodel): @@ -223,17 +231,21 @@ def _parse_aas_part(self, part_name: str, **kwargs) -> model.DictObjectStore: content_type = self.reader.get_content_type(part_name) extension = part_name.split("/")[-1].split(".")[-1] if content_type.split(";")[0] in ("text/xml", "application/xml") or content_type == "" and extension == "xml": - logger.debug("Parsing AAS objects from XML stream in OPC part {} ...".format(part_name)) + logger.debug(f"Parsing AAS objects from XML stream in OPC part {part_name} ...") with self.reader.open_part(part_name) as p: - return read_aas_xml_file(p, **kwargs) + return read_aas_xml_file(p, failsafe=self.failsafe, **kwargs) elif content_type.split(";")[0] in ("text/json", "application/json") \ or content_type == "" and extension == "json": - logger.debug("Parsing AAS objects from JSON stream in OPC part {} ...".format(part_name)) + logger.debug(f"Parsing AAS objects from JSON stream in OPC part {part_name} ...") with self.reader.open_part(part_name) as p: - return read_aas_json_file(io.TextIOWrapper(p, encoding='utf-8-sig'), **kwargs) + return read_aas_json_file(io.TextIOWrapper(p, encoding='utf-8-sig'), failsafe=self.failsafe, **kwargs) else: - logger.error("Could not determine part format of AASX part {} (Content Type: {}, extension: {}" - .format(part_name, content_type, extension)) + error_message = (f"Could not determine part format of AASX part {part_name} (Content Type: {content_type}," + f" extension: {extension}") + if self.failsafe: + logger.error(error_message) + else: + raise ValueError(error_message) return model.DictObjectStore() def _collect_supplementary_files(self, part_name: str, submodel: model.Submodel, @@ -242,7 +254,7 @@ def _collect_supplementary_files(self, part_name: str, submodel: model.Submodel, Helper function to search File objects within a single parsed Submodel, extract the referenced supplementary files and update the File object's values with the absolute path. - :param part_name: The OPC part name of the part the submodel has been parsed from. This is used to resolve + :param part_name: The OPC part name of the part the Submodel has been parsed from. This is used to resolve relative file paths. :param submodel: The Submodel to process :param file_store: The SupplementaryFileContainer to add the extracted supplementary files to @@ -255,11 +267,11 @@ def _collect_supplementary_files(self, part_name: str, submodel: model.Submodel, # to refer to files within the AASX package. Thus, we must skip all other types of URIs (esp. absolute # URIs and network-path references) if element.value.startswith('//') or ':' in element.value.split('/')[0]: - logger.info("Skipping supplementary file %s, since it seems to be an absolute URI or network-path " - "URI reference", element.value) + logger.info(f"Skipping supplementary file {element.value}, since it seems to be an absolute URI or " + f"network-path URI reference") continue absolute_name = pyecma376_2.package_model.part_realpath(element.value, part_name) - logger.debug("Reading supplementary file {} from AASX package ...".format(absolute_name)) + logger.debug(f"Reading supplementary file {absolute_name} from AASX package ...") with self.reader.open_part(absolute_name) as p: final_name = file_store.add_file(absolute_name, p, self.reader.get_content_type(absolute_name)) element.value = final_name @@ -295,7 +307,7 @@ class AASXWriter: """ AASX_ORIGIN_PART_NAME = "/aasx/aasx-origin" - def __init__(self, file: Union[os.PathLike, str, IO]): + def __init__(self, file: Union[os.PathLike, str, IO], failsafe: bool = True): """ Create a new AASX package in the given file and open the AASXWriter to add contents to the package. @@ -303,8 +315,11 @@ def __init__(self, file: Union[os.PathLike, str, IO]): AAS parts to the file and close the underlying ZIP file writer. You may also use the AASXWriter as a context manager to ensure closing under any circumstances. + :param failsafe: If ``True``, the document is written in a failsafe way: Missing attributes and elements are + logged instead of causing exceptions. Defect objects are skipped. :param file: filename, path, or binary file handle opened for writing """ + self.failsafe: bool = failsafe # names of aas-spec parts, used by `_write_aasx_origin_relationships()` self._aas_part_names: List[str] = [] # name of the thumbnail part (if any) @@ -364,7 +379,7 @@ def write_aas(self, :param write_json: If ``True``, JSON parts are created for the AAS and each :class:`~basyx.aas.model.submodel.Submodel` in the AASX package file instead of XML parts. Defaults to ``False``. - :raises KeyError: If one of the AAS could not be retrieved from the object store (unresolvable + :raises KeyError: If one of the AAS could not be retrieved from the ObjectStore (unresolvable :class:`Submodels ` and :class:`ConceptDescriptions ` are skipped, logging a warning/info message) @@ -378,12 +393,15 @@ def write_aas(self, for aas_id in aas_ids: try: aas = object_store.get_identifiable(aas_id) - # TODO add failsafe mode - except KeyError: - raise - if not isinstance(aas, model.AssetAdministrationShell): - raise TypeError(f"Identifier {aas_id} does not belong to an AssetAdministrationShell object but to " - f"{aas!r}") + if not isinstance(aas, model.AssetAdministrationShell): + raise TypeError(f"Identifier {aas_id} does not belong to an AssetAdministrationShell object but to " + f"{aas!r}") + except (KeyError, TypeError) as e: + if self.failsafe: + logger.error(f"Skipping AAS {aas_id}: {e}") + continue + else: + raise # Add the AssetAdministrationShell object to the data part objects_to_be_written.add(aas) @@ -393,8 +411,11 @@ def write_aas(self, try: submodel = submodel_ref.resolve(object_store) except KeyError: - logger.warning("Could not find submodel %s. Skipping it.", str(submodel_ref)) - continue + if self.failsafe: + logger.warning(f"Could not find Submodel {submodel_ref}. Skipping it.") + continue + else: + raise KeyError(f"Could not find Submodel {submodel_ref!r}") objects_to_be_written.add(submodel) # Traverse object tree and check if semanticIds are referencing to existing ConceptDescriptions in the @@ -410,13 +431,20 @@ def write_aas(self, try: cd = semantic_id.resolve(object_store) except KeyError: - logger.warning("ConceptDescription for semanticId %s not found in object store. Skipping it.", - str(semantic_id)) - continue + if self.failsafe: + logger.warning(f"ConceptDescription for semanticId {semantic_id} not found in ObjectStore. " + f"Skipping it.") + continue + else: + raise KeyError(f"ConceptDescription for semanticId {semantic_id!r} not found in ObjectStore.") except model.UnexpectedTypeError as e: - logger.error("semanticId %s resolves to %s, which is not a ConceptDescription. Skipping it.", - str(semantic_id), e.value) - continue + if self.failsafe: + logger.error(f"semanticId {semantic_id} resolves to {e.value}, " + f"which is not a ConceptDescription. Skipping it.") + continue + else: + raise TypeError(f"semanticId {semantic_id!r} resolves to {e.value!r}, which is not a" + f" ConceptDescription.") from e concept_descriptions.append(cd) objects_to_be_written.update(concept_descriptions) @@ -440,7 +468,7 @@ def write_aas_objects(self, This method takes the AAS's :class:`~basyx.aas.model.base.Identifier` (as ``aas_id``) to retrieve it from the given object_store. If the list of written objects includes :class:`~basyx.aas.model.submodel.Submodel` objects, Supplementary files which are referenced by :class:`~basyx.aas.model.submodel.File` objects within - those submodels, are also added to the AASX package. + those Submodels, are also added to the AASX package. .. attention:: @@ -465,7 +493,7 @@ def write_aas_objects(self, :param additional_relationships: Optional OPC/ECMA376 relationships which should originate at the AAS object part to be written, in addition to the aas-suppl relationships which are created automatically. """ - logger.debug("Writing AASX part {} with AAS objects ...".format(part_name)) + logger.debug(f"Writing AASX part {part_name} with AAS objects ...") objects: model.DictObjectStore[model.Identifiable] = model.DictObjectStore() @@ -474,8 +502,11 @@ def write_aas_objects(self, try: the_object = object_store.get_identifiable(identifier) except KeyError: - logger.error("Could not find object {} in ObjectStore".format(identifier)) - continue + if self.failsafe: + logger.error(f"Could not find object {identifier} in ObjectStore") + continue + else: + raise KeyError(f"Could not find object {identifier!r} in ObjectStore") objects.add(the_object) self.write_all_aas_objects(part_name, objects, file_store, write_json, split_part, additional_relationships) @@ -516,7 +547,7 @@ def write_all_aas_objects(self, :param additional_relationships: Optional OPC/ECMA376 relationships which should originate at the AAS object part to be written, in addition to the aas-suppl relationships which are created automatically. """ - logger.debug("Writing AASX part {} with AAS objects ...".format(part_name)) + logger.debug(f"Writing AASX part {part_name} with AAS objects ...") supplementary_files: List[str] = [] # Retrieve objects and scan for referenced supplementary files @@ -543,29 +574,36 @@ def write_all_aas_objects(self, else: write_aas_xml_file(p, objects) - # Write submodel's supplementary files to AASX file + # Write Submodel's supplementary files to AASX file supplementary_file_names = [] for file_name in supplementary_files: try: content_type = file_store.get_content_type(file_name) hash = file_store.get_sha256(file_name) except KeyError: - logger.warning("Could not find file {} in file store.".format(file_name)) - continue + if self.failsafe: + logger.warning(f"Could not find file {file_name} in FileStore.") + continue + else: + raise KeyError(f"Could not find file {file_name} in FileStore.") # Check if this supplementary file has already been written to the AASX package or has a name conflict if self._supplementary_part_names.get(file_name) == hash: continue elif file_name in self._supplementary_part_names: - logger.error("Trying to write supplementary file {} to AASX twice with different contents" - .format(file_name)) - logger.debug("Writing supplementary file {} to AASX package ...".format(file_name)) + if self.failsafe: + logger.error(f"Trying to write supplementary file {file_name} to AASX " + f"twice with different contents") + else: + raise ValueError(f"Trying to write supplementary file {file_name} to AASX twice with" + f" different contents") + logger.debug(f"Writing supplementary file {file_name} to AASX package ...") with self.writer.open_part(file_name, content_type) as p: file_store.write_file(file_name, p) supplementary_file_names.append(pyecma376_2.package_model.normalize_part_name(file_name)) self._supplementary_part_names[file_name] = hash - # Add relationships from submodel to supplementary parts - logger.debug("Writing aas-suppl relationships for AAS object part {} to AASX package ...".format(part_name)) + # Add relationships from Submodel to supplementary parts + logger.debug(f"Writing aas-suppl relationships for AAS object part {part_name} to AASX package ...") self.writer.write_relationships( itertools.chain( (pyecma376_2.OPCRelationship("r{}".format(i), @@ -604,7 +642,7 @@ def write_thumbnail(self, name: str, data: bytearray, content_type: str): :param content_type: OPC content type (MIME type) of the image file """ if self._thumbnail_part is not None: - raise RuntimeError("package thumbnail has already been written to {}.".format(self._thumbnail_part)) + raise RuntimeError(f"package thumbnail has already been written to {self._thumbnail_part}.") with self.writer.open_part(name, content_type) as p: p.write(data) self._thumbnail_part = name diff --git a/sdk/basyx/aas/adapter/json/json_deserialization.py b/sdk/basyx/aas/adapter/json/json_deserialization.py index 78e3713f5..1e26eebfb 100644 --- a/sdk/basyx/aas/adapter/json/json_deserialization.py +++ b/sdk/basyx/aas/adapter/json/json_deserialization.py @@ -885,13 +885,15 @@ def read_aas_json_file_into(object_store: model.AbstractObjectStore, file: PathO return ret -def read_aas_json_file(file: PathOrIO, **kwargs) -> model.DictObjectStore[model.Identifiable]: +def read_aas_json_file(file: PathOrIO, failsafe: bool = True, **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 failsafe: If ``True``, the document is parsed in a failsafe way: Missing attributes and elements are logged + instead of causing exceptions. Defect objects are skipped. :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**: @@ -901,5 +903,5 @@ def read_aas_json_file(file: PathOrIO, **kwargs) -> model.DictObjectStore[model. :return: A :class:`~basyx.aas.model.provider.DictObjectStore` containing all AAS objects from the JSON file """ object_store: model.DictObjectStore[model.Identifiable] = model.DictObjectStore() - read_aas_json_file_into(object_store, file, **kwargs) + read_aas_json_file_into(object_store, file, failsafe=failsafe, **kwargs) return object_store diff --git a/sdk/basyx/aas/adapter/xml/xml_deserialization.py b/sdk/basyx/aas/adapter/xml/xml_deserialization.py index 4063b8df7..b263820d1 100644 --- a/sdk/basyx/aas/adapter/xml/xml_deserialization.py +++ b/sdk/basyx/aas/adapter/xml/xml_deserialization.py @@ -1502,13 +1502,16 @@ def read_aas_xml_file_into(object_store: model.AbstractObjectStore[model.Identif return ret -def read_aas_xml_file(file: PathOrIO, **kwargs: Any) -> model.DictObjectStore[model.Identifiable]: +def read_aas_xml_file(file: PathOrIO, failsafe: bool = True, **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 failsafe: If ``True``, the document is parsed in a failsafe way: Missing attributes and elements are logged + instead of causing exceptions. Defect objects are skipped. :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 @@ -1519,5 +1522,5 @@ def read_aas_xml_file(file: PathOrIO, **kwargs: Any) -> model.DictObjectStore[mo :return: A :class:`~basyx.aas.model.provider.DictObjectStore` containing all AAS objects from the XML file """ object_store: model.DictObjectStore[model.Identifiable] = model.DictObjectStore() - read_aas_xml_file_into(object_store, file, **kwargs) + read_aas_xml_file_into(object_store, file, failsafe=failsafe, **kwargs) return object_store