Skip to content

Commit e043960

Browse files
authored
aasx.py: Add failsafe for writing and reading AASX files (#378)
Previously, the `failsafe` parameter, allowing to skip errors and only log them as warnings instead, only existed for JSON and XML (de-)serialization. This PR extends the feature to also be included when reading and writing AASX files. For this, we add the `failsafe` boolean attribute to the `AASXReader` and `AASXWriter` classes in `adapter.aasx`. If `failsafe` is `True`, the document is parsed in a failsafe way: Missing attributes and elements are logged instead of causing exceptions. Defect objects are skipped. The default value is `False`, keeping the existing behavior. Furthermore, we do a little code-style clean up on string-formatting in the `adapter.aasx` module, updating all occurrences from (historically) different syntaxes to the more modern f-string syntax. Fixes #228
1 parent 1f6336c commit e043960

File tree

3 files changed

+98
-55
lines changed

3 files changed

+98
-55
lines changed

sdk/basyx/aas/adapter/aasx.py

Lines changed: 89 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
import itertools
2929
import logging
3030
import os
31-
import re
3231
from typing import Dict, Tuple, IO, Union, List, Set, Optional, Iterable, Iterator
3332

3433
from .xml import read_aas_xml_file, write_aas_xml_file
@@ -60,7 +59,7 @@ class AASXReader:
6059
reader.read_into(objects, files)
6160
6261
"""
63-
def __init__(self, file: Union[os.PathLike, str, IO]):
62+
def __init__(self, file: Union[os.PathLike, str, IO], failsafe: bool = True):
6463
"""
6564
Open an AASX reader for the given filename or file handle
6665
@@ -69,16 +68,19 @@ def __init__(self, file: Union[os.PathLike, str, IO]):
6968
closing under any circumstances.
7069
7170
:param file: A filename, file path or an open file-like object in binary mode
71+
:param failsafe: If ``True``, the document is parsed in a failsafe way: Missing attributes and elements are
72+
logged instead of causing exceptions. Defect objects are skipped.
7273
:raises FileNotFoundError: If the file does not exist
7374
:raises ValueError: If the file is not a valid OPC zip package
7475
"""
76+
self.failsafe: bool = failsafe
7577
try:
76-
logger.debug("Opening {} as AASX pacakge for reading ...".format(file))
78+
logger.debug(f"Opening {file} as AASX package for reading ...")
7779
self.reader = pyecma376_2.ZipPackageReader(file)
7880
except FileNotFoundError:
7981
raise
8082
except Exception as e:
81-
raise ValueError("{} is not a valid ECMA376-2 (OPC) file: {}".format(file, e)) from e
83+
raise ValueError(f"{file} is not a valid ECMA376-2 (OPC) file: {e}") from e
8284

8385
def get_core_properties(self) -> pyecma376_2.OPCCoreProperties:
8486
"""
@@ -132,7 +134,7 @@ def read_into(self, object_store: model.AbstractObjectStore,
132134
objects from the AASX file to
133135
:param file_store: A :class:`SupplementaryFileContainer <.AbstractSupplementaryFileContainer>` to add the
134136
embedded supplementary files to
135-
:param override_existing: If ``True``, existing objects in the object store are overridden with objects from the
137+
:param override_existing: If ``True``, existing objects in the ObjectStore are overridden with objects from the
136138
AASX that have the same :class:`~basyx.aas.model.base.Identifier`. Default behavior is to skip those objects
137139
from the AASX.
138140
:return: A set of the :class:`Identifiers <basyx.aas.model.base.Identifier>` of all
@@ -172,7 +174,10 @@ def read_into(self, object_store: model.AbstractObjectStore,
172174
self._read_aas_part_into(split_part, object_store, file_store,
173175
read_identifiables, override_existing, **kwargs)
174176
if no_aas_files_found:
175-
logger.warning("No AAS files found in AASX package")
177+
if self.failsafe:
178+
logger.warning("No AAS files found in AASX package")
179+
else:
180+
raise ValueError("No AAS files found in AASX package")
176181

177182
return read_identifiables
178183

@@ -205,20 +210,23 @@ def _read_aas_part_into(self, part_name: str,
205210
from a File object of this part
206211
:param read_identifiables: A set of Identifiers of objects which have already been read. New objects'
207212
Identifiers are added to this set. Objects with already known Identifiers are skipped silently.
208-
:param override_existing: If True, existing objects in the object store are overridden with objects from the
213+
:param override_existing: If True, existing objects in the ObjectStore are overridden with objects from the
209214
AASX that have the same Identifier. Default behavior is to skip those objects from the AASX.
210215
"""
211216
for obj in self._parse_aas_part(part_name, **kwargs):
212217
if obj.id in read_identifiables:
213218
continue
214219
if obj.id in object_store:
215220
if override_existing:
216-
logger.info("Overriding existing object in ObjectStore with {} ...".format(obj))
221+
logger.info(f"Overriding existing object in ObjectStore with {obj} ...")
217222
object_store.discard(obj)
218223
else:
219-
logger.warning("Skipping {}, since an object with the same id is already contained in the "
220-
"ObjectStore".format(obj))
221-
continue
224+
if self.failsafe:
225+
logger.warning(f"Skipping {obj}, since an object with the same id is already contained in the "
226+
"ObjectStore")
227+
continue
228+
else:
229+
raise ValueError(f"Object with id {obj} is already contained in the ObjectStore")
222230
object_store.add(obj)
223231
read_identifiables.add(obj.id)
224232
if isinstance(obj, model.Submodel):
@@ -236,17 +244,21 @@ def _parse_aas_part(self, part_name: str, **kwargs) -> model.DictObjectStore:
236244
content_type = self.reader.get_content_type(part_name)
237245
extension = part_name.split("/")[-1].split(".")[-1]
238246
if content_type.split(";")[0] in ("text/xml", "application/xml") or content_type == "" and extension == "xml":
239-
logger.debug("Parsing AAS objects from XML stream in OPC part {} ...".format(part_name))
247+
logger.debug(f"Parsing AAS objects from XML stream in OPC part {part_name} ...")
240248
with self.reader.open_part(part_name) as p:
241-
return read_aas_xml_file(p, **kwargs)
249+
return read_aas_xml_file(p, failsafe=self.failsafe, **kwargs)
242250
elif content_type.split(";")[0] in ("text/json", "application/json") \
243251
or content_type == "" and extension == "json":
244-
logger.debug("Parsing AAS objects from JSON stream in OPC part {} ...".format(part_name))
252+
logger.debug(f"Parsing AAS objects from JSON stream in OPC part {part_name} ...")
245253
with self.reader.open_part(part_name) as p:
246-
return read_aas_json_file(io.TextIOWrapper(p, encoding='utf-8-sig'), **kwargs)
254+
return read_aas_json_file(io.TextIOWrapper(p, encoding='utf-8-sig'), failsafe=self.failsafe, **kwargs)
247255
else:
248-
logger.error("Could not determine part format of AASX part {} (Content Type: {}, extension: {}"
249-
.format(part_name, content_type, extension))
256+
error_message = (f"Could not determine part format of AASX part {part_name} (Content Type: {content_type},"
257+
f" extension: {extension}")
258+
if self.failsafe:
259+
logger.error(error_message)
260+
else:
261+
raise ValueError(error_message)
250262
return model.DictObjectStore()
251263

252264
def _collect_supplementary_files(self, part_name: str, submodel: model.Submodel,
@@ -255,7 +267,7 @@ def _collect_supplementary_files(self, part_name: str, submodel: model.Submodel,
255267
Helper function to search File objects within a single parsed Submodel, extract the referenced supplementary
256268
files and update the File object's values with the absolute path.
257269
258-
:param part_name: The OPC part name of the part the submodel has been parsed from. This is used to resolve
270+
:param part_name: The OPC part name of the part the Submodel has been parsed from. This is used to resolve
259271
relative file paths.
260272
:param submodel: The Submodel to process
261273
:param file_store: The SupplementaryFileContainer to add the extracted supplementary files to
@@ -268,11 +280,11 @@ def _collect_supplementary_files(self, part_name: str, submodel: model.Submodel,
268280
# to refer to files within the AASX package. Thus, we must skip all other types of URIs (esp. absolute
269281
# URIs and network-path references)
270282
if element.value.startswith('//') or ':' in element.value.split('/')[0]:
271-
logger.info("Skipping supplementary file %s, since it seems to be an absolute URI or network-path "
272-
"URI reference", element.value)
283+
logger.info(f"Skipping supplementary file {element.value}, since it seems to be an absolute URI or "
284+
f"network-path URI reference")
273285
continue
274286
absolute_name = pyecma376_2.package_model.part_realpath(element.value, part_name)
275-
logger.debug("Reading supplementary file {} from AASX package ...".format(absolute_name))
287+
logger.debug(f"Reading supplementary file {absolute_name} from AASX package ...")
276288
with self.reader.open_part(absolute_name) as p:
277289
final_name = file_store.add_file(absolute_name, p, self.reader.get_content_type(absolute_name))
278290
element.value = final_name
@@ -308,16 +320,19 @@ class AASXWriter:
308320
"""
309321
AASX_ORIGIN_PART_NAME = "/aasx/aasx-origin"
310322

311-
def __init__(self, file: Union[os.PathLike, str, IO]):
323+
def __init__(self, file: Union[os.PathLike, str, IO], failsafe: bool = True):
312324
"""
313325
Create a new AASX package in the given file and open the AASXWriter to add contents to the package.
314326
315327
Make sure to call ``AASXWriter.close()`` after writing all contents to write the aas-spec relationships for all
316328
AAS parts to the file and close the underlying ZIP file writer. You may also use the AASXWriter as a context
317329
manager to ensure closing under any circumstances.
318330
331+
:param failsafe: If ``True``, the document is written in a failsafe way: Missing attributes and elements are
332+
logged instead of causing exceptions. Defect objects are skipped.
319333
:param file: filename, path, or binary file handle opened for writing
320334
"""
335+
self.failsafe: bool = failsafe
321336
# names of aas-spec parts, used by `_write_aasx_origin_relationships()`
322337
self._aas_part_names: List[str] = []
323338
# name of the thumbnail part (if any)
@@ -377,7 +392,7 @@ def write_aas(self,
377392
:param write_json: If ``True``, JSON parts are created for the AAS and each
378393
:class:`~basyx.aas.model.submodel.Submodel` in the AASX package file instead of XML parts.
379394
Defaults to ``False``.
380-
:raises KeyError: If one of the AAS could not be retrieved from the object store (unresolvable
395+
:raises KeyError: If one of the AAS could not be retrieved from the ObjectStore (unresolvable
381396
:class:`Submodels <basyx.aas.model.submodel.Submodel>` and
382397
:class:`ConceptDescriptions <basyx.aas.model.concept.ConceptDescription>` are skipped, logging a
383398
warning/info message)
@@ -391,12 +406,15 @@ def write_aas(self,
391406
for aas_id in aas_ids:
392407
try:
393408
aas = object_store.get_identifiable(aas_id)
394-
# TODO add failsafe mode
395-
except KeyError:
396-
raise
397-
if not isinstance(aas, model.AssetAdministrationShell):
398-
raise TypeError(f"Identifier {aas_id} does not belong to an AssetAdministrationShell object but to "
399-
f"{aas!r}")
409+
if not isinstance(aas, model.AssetAdministrationShell):
410+
raise TypeError(f"Identifier {aas_id} does not belong to an AssetAdministrationShell object but to "
411+
f"{aas!r}")
412+
except (KeyError, TypeError) as e:
413+
if self.failsafe:
414+
logger.error(f"Skipping AAS {aas_id}: {e}")
415+
continue
416+
else:
417+
raise
400418

401419
# Add the AssetAdministrationShell object to the data part
402420
objects_to_be_written.add(aas)
@@ -406,8 +424,11 @@ def write_aas(self,
406424
try:
407425
submodel = submodel_ref.resolve(object_store)
408426
except KeyError:
409-
logger.warning("Could not find submodel %s. Skipping it.", str(submodel_ref))
410-
continue
427+
if self.failsafe:
428+
logger.warning(f"Could not find Submodel {submodel_ref}. Skipping it.")
429+
continue
430+
else:
431+
raise KeyError(f"Could not find Submodel {submodel_ref!r}")
411432
objects_to_be_written.add(submodel)
412433

413434
# Traverse object tree and check if semanticIds are referencing to existing ConceptDescriptions in the
@@ -423,13 +444,20 @@ def write_aas(self,
423444
try:
424445
cd = semantic_id.resolve(object_store)
425446
except KeyError:
426-
logger.warning("ConceptDescription for semanticId %s not found in object store. Skipping it.",
427-
str(semantic_id))
428-
continue
447+
if self.failsafe:
448+
logger.warning(f"ConceptDescription for semanticId {semantic_id} not found in ObjectStore. "
449+
f"Skipping it.")
450+
continue
451+
else:
452+
raise KeyError(f"ConceptDescription for semanticId {semantic_id!r} not found in ObjectStore.")
429453
except model.UnexpectedTypeError as e:
430-
logger.error("semanticId %s resolves to %s, which is not a ConceptDescription. Skipping it.",
431-
str(semantic_id), e.value)
432-
continue
454+
if self.failsafe:
455+
logger.error(f"semanticId {semantic_id} resolves to {e.value}, "
456+
f"which is not a ConceptDescription. Skipping it.")
457+
continue
458+
else:
459+
raise TypeError(f"semanticId {semantic_id!r} resolves to {e.value!r}, which is not a"
460+
f" ConceptDescription.") from e
433461
concept_descriptions.append(cd)
434462
objects_to_be_written.update(concept_descriptions)
435463

@@ -453,7 +481,7 @@ def write_aas_objects(self,
453481
This method takes the AAS's :class:`~basyx.aas.model.base.Identifier` (as ``aas_id``) to retrieve it
454482
from the given object_store. If the list of written objects includes :class:`~basyx.aas.model.submodel.Submodel`
455483
objects, Supplementary files which are referenced by :class:`~basyx.aas.model.submodel.File` objects within
456-
those submodels, are also added to the AASX package.
484+
those Submodels, are also added to the AASX package.
457485
458486
.. attention::
459487
@@ -478,7 +506,7 @@ def write_aas_objects(self,
478506
:param additional_relationships: Optional OPC/ECMA376 relationships which should originate at the AAS object
479507
part to be written, in addition to the aas-suppl relationships which are created automatically.
480508
"""
481-
logger.debug("Writing AASX part {} with AAS objects ...".format(part_name))
509+
logger.debug(f"Writing AASX part {part_name} with AAS objects ...")
482510

483511
objects: model.DictObjectStore[model.Identifiable] = model.DictObjectStore()
484512

@@ -487,8 +515,11 @@ def write_aas_objects(self,
487515
try:
488516
the_object = object_store.get_identifiable(identifier)
489517
except KeyError:
490-
logger.error("Could not find object {} in ObjectStore".format(identifier))
491-
continue
518+
if self.failsafe:
519+
logger.error(f"Could not find object {identifier} in ObjectStore")
520+
continue
521+
else:
522+
raise KeyError(f"Could not find object {identifier!r} in ObjectStore")
492523
objects.add(the_object)
493524

494525
self.write_all_aas_objects(part_name, objects, file_store, write_json, split_part, additional_relationships)
@@ -529,7 +560,7 @@ def write_all_aas_objects(self,
529560
:param additional_relationships: Optional OPC/ECMA376 relationships which should originate at the AAS object
530561
part to be written, in addition to the aas-suppl relationships which are created automatically.
531562
"""
532-
logger.debug("Writing AASX part {} with AAS objects ...".format(part_name))
563+
logger.debug(f"Writing AASX part {part_name} with AAS objects ...")
533564
supplementary_files: List[str] = []
534565

535566
# Retrieve objects and scan for referenced supplementary files
@@ -556,29 +587,36 @@ def write_all_aas_objects(self,
556587
else:
557588
write_aas_xml_file(p, objects)
558589

559-
# Write submodel's supplementary files to AASX file
590+
# Write Submodel's supplementary files to AASX file
560591
supplementary_file_names = []
561592
for file_name in supplementary_files:
562593
try:
563594
content_type = file_store.get_content_type(file_name)
564595
hash = file_store.get_sha256(file_name)
565596
except KeyError:
566-
logger.warning("Could not find file {} in file store.".format(file_name))
567-
continue
597+
if self.failsafe:
598+
logger.warning(f"Could not find file {file_name} in FileStore.")
599+
continue
600+
else:
601+
raise KeyError(f"Could not find file {file_name} in FileStore.")
568602
# Check if this supplementary file has already been written to the AASX package or has a name conflict
569603
if self._supplementary_part_names.get(file_name) == hash:
570604
continue
571605
elif file_name in self._supplementary_part_names:
572-
logger.error("Trying to write supplementary file {} to AASX twice with different contents"
573-
.format(file_name))
574-
logger.debug("Writing supplementary file {} to AASX package ...".format(file_name))
606+
if self.failsafe:
607+
logger.error(f"Trying to write supplementary file {file_name} to AASX "
608+
f"twice with different contents")
609+
else:
610+
raise ValueError(f"Trying to write supplementary file {file_name} to AASX twice with"
611+
f" different contents")
612+
logger.debug(f"Writing supplementary file {file_name} to AASX package ...")
575613
with self.writer.open_part(file_name, content_type) as p:
576614
file_store.write_file(file_name, p)
577615
supplementary_file_names.append(pyecma376_2.package_model.normalize_part_name(file_name))
578616
self._supplementary_part_names[file_name] = hash
579617

580-
# Add relationships from submodel to supplementary parts
581-
logger.debug("Writing aas-suppl relationships for AAS object part {} to AASX package ...".format(part_name))
618+
# Add relationships from Submodel to supplementary parts
619+
logger.debug(f"Writing aas-suppl relationships for AAS object part {part_name} to AASX package ...")
582620
self.writer.write_relationships(
583621
itertools.chain(
584622
(pyecma376_2.OPCRelationship("r{}".format(i),
@@ -617,7 +655,7 @@ def write_thumbnail(self, name: str, data: bytearray, content_type: str):
617655
:param content_type: OPC content type (MIME type) of the image file
618656
"""
619657
if self._thumbnail_part is not None:
620-
raise RuntimeError("package thumbnail has already been written to {}.".format(self._thumbnail_part))
658+
raise RuntimeError(f"package thumbnail has already been written to {self._thumbnail_part}.")
621659
with self.writer.open_part(name, content_type) as p:
622660
p.write(data)
623661
self._thumbnail_part = name

0 commit comments

Comments
 (0)