From 387019ef9aaf6a7314b68520c3f99b9b37f7b4f6 Mon Sep 17 00:00:00 2001 From: Andy Grigg Date: Mon, 3 Jun 2024 09:11:29 -0400 Subject: [PATCH] Empty elements are no longer removed when deserializing a BoM file (#546) Co-authored-by: pyansys-ci-bot --- doc/changelog.d/546.fixed.md | 1 + .../grantami/bomanalytics/_bom_helper.py | 23 ++++++++-- tests/inputs/__init__.py | 12 ++--- tests/inputs/bom.xml | 2 +- tests/test_bom_handler.py | 45 +++++++++++++++---- 5 files changed, 64 insertions(+), 19 deletions(-) create mode 100644 doc/changelog.d/546.fixed.md diff --git a/doc/changelog.d/546.fixed.md b/doc/changelog.d/546.fixed.md new file mode 100644 index 00000000..045cb392 --- /dev/null +++ b/doc/changelog.d/546.fixed.md @@ -0,0 +1 @@ +Empty elements are no longer removed when deserializing a BoM file \ No newline at end of file diff --git a/src/ansys/grantami/bomanalytics/_bom_helper.py b/src/ansys/grantami/bomanalytics/_bom_helper.py index d11912a1..ddd7a8b5 100644 --- a/src/ansys/grantami/bomanalytics/_bom_helper.py +++ b/src/ansys/grantami/bomanalytics/_bom_helper.py @@ -21,7 +21,7 @@ # SOFTWARE. from pathlib import Path -from typing import TYPE_CHECKING, Tuple, cast +from typing import TYPE_CHECKING, Any, Dict, List, TextIO, Tuple, Union, cast import xmlschema from xmlschema import XMLSchema @@ -63,7 +63,7 @@ def load_bom_from_file(self, file_path: Path) -> "BillOfMaterials": :class:`~._bom_types.BillOfMaterials` """ with open(file_path, "r", encoding="utf8") as fp: - obj, errors = cast(Tuple, self._schema.decode(fp, validation="lax")) + obj, errors = self._deserialize_bom(fp) if len(errors) > 0: newline = "\n" @@ -86,7 +86,7 @@ def load_bom_from_text(self, bom_text: str) -> "BillOfMaterials": ------- :class:`~._bom_types.BillOfMaterials` """ - obj, errors = cast(Tuple, self._schema.decode(bom_text, validation="lax", keep_empty=True)) + obj, errors = self._deserialize_bom(bom_text) if len(errors) > 0: newline = "\n" @@ -96,6 +96,23 @@ def load_bom_from_text(self, bom_text: str) -> "BillOfMaterials": return self._reader.read_bom(obj) + def _deserialize_bom(self, bom: Union[TextIO, str]) -> Tuple[Dict[str, Any], List]: + """ + Deserialize either a string or I/O stream BoM. + + Parameters + ---------- + bom : Union[TextIO, str] + Object containing an XML representation of a BoM, either as text or I/O stream. + + Returns + ------- + Tuple[Dict[str, Any], List] + A tuple of the deserialized dictionary and a list of errors. + """ + result = self._schema.decode(bom, validation="lax", keep_empty=True, xmlns_processing="collapsed") + return cast(Tuple[Dict[str, Any], List], result) + def dump_bom(self, bom: "BillOfMaterials") -> str: """ Convert a BillOfMaterials object into a string XML representation. diff --git a/tests/inputs/__init__.py b/tests/inputs/__init__.py index dab2495c..95b18f47 100644 --- a/tests/inputs/__init__.py +++ b/tests/inputs/__init__.py @@ -28,25 +28,25 @@ inputs_dir = pathlib.Path(__file__).parent _sample_bom_1711_path = inputs_dir / "bom.xml" -with open(_sample_bom_1711_path, "r") as f: +with open(_sample_bom_1711_path, "r", encoding="utf8") as f: sample_bom_1711 = f.read() _sample_compliance_bom_1711_path = ( repository_root / "examples" / "3_Advanced_Topics" / "supporting-files" / "bom-complex.xml" ) -with open(_sample_compliance_bom_1711_path, "r") as f: +with open(_sample_compliance_bom_1711_path, "r", encoding="utf8") as f: sample_compliance_bom_1711 = f.read() sample_bom_custom_db = sample_compliance_bom_1711.replace( "MI_Restricted_Substances", "MI_Restricted_Substances_Custom_Tables" ) -_sample_sustainability_bom_2301_path = ( +sample_sustainability_bom_2301_path = ( repository_root / "examples" / "4_Sustainability" / "supporting-files" / "bom-2301-assembly.xml" ) -with open(_sample_sustainability_bom_2301_path, "r") as f: +with open(sample_sustainability_bom_2301_path, "r", encoding="utf8") as f: sample_sustainability_bom_2301 = f.read() -_large_bom_2301_path = inputs_dir / "medium-test-bom.xml" -with open(_large_bom_2301_path, "r") as f: +large_bom_2301_path = inputs_dir / "medium-test-bom.xml" +with open(large_bom_2301_path, "r", encoding="utf8") as f: large_bom_2301 = f.read() diff --git a/tests/inputs/bom.xml b/tests/inputs/bom.xml index da158552..98f0c7d6 100644 --- a/tests/inputs/bom.xml +++ b/tests/inputs/bom.xml @@ -24,7 +24,7 @@ 1.0 2.0 - 3333 + Part Two diff --git a/tests/test_bom_handler.py b/tests/test_bom_handler.py index 3fe751f6..70011ef2 100644 --- a/tests/test_bom_handler.py +++ b/tests/test_bom_handler.py @@ -30,7 +30,13 @@ from ansys.grantami.bomanalytics import BoMHandler, bom_types -from .inputs import large_bom_2301, sample_bom_1711, sample_sustainability_bom_2301 +from .inputs import ( + large_bom_2301, + large_bom_2301_path, + sample_bom_1711, + sample_sustainability_bom_2301, + sample_sustainability_bom_2301_path, +) class _TestableBoMHandler(BoMHandler): @@ -80,33 +86,54 @@ def _compare_boms(*, source_bom: str, result_bom: str): output_lines.append(diff_item) return output_lines + def test_roundtrip_from_text_with_assertions(self): + bom_handler = _TestableBoMHandler( + default_namespace=self._default_namespace, namespace_mapping=self._namespace_map + ) + deserialized_bom = bom_handler.load_bom_from_text(large_bom_2301) + output_bom = bom_handler.dump_bom(deserialized_bom) + + diff = self._compare_boms(source_bom=large_bom_2301, result_bom=output_bom) + + assert len(diff) == 0, "\n".join(diff) + @pytest.mark.parametrize( "input_bom", [ pytest.param(large_bom_2301, id="large_bom"), + pytest.param(sample_sustainability_bom_2301, id="sustainability_bom"), ], ) - def test_roundtrip_with_assertions(self, input_bom: str): + def test_roundtrip_from_text_parsing_succeeds(self, input_bom: str): + bom_handler = BoMHandler() + deserialized_bom = bom_handler.load_bom_from_text(input_bom) + + rendered_bom = bom_handler.dump_bom(deserialized_bom) + deserialized_bom_roundtriped = bom_handler.load_bom_from_text(rendered_bom) + + assert deserialized_bom == deserialized_bom_roundtriped + + def test_roundtrip_from_file_with_assertions(self): bom_handler = _TestableBoMHandler( default_namespace=self._default_namespace, namespace_mapping=self._namespace_map ) - deserialized_bom = bom_handler.load_bom_from_text(input_bom) + deserialized_bom = bom_handler.load_bom_from_file(large_bom_2301_path) output_bom = bom_handler.dump_bom(deserialized_bom) - diff = self._compare_boms(source_bom=input_bom, result_bom=output_bom) + diff = self._compare_boms(source_bom=large_bom_2301, result_bom=output_bom) assert len(diff) == 0, "\n".join(diff) @pytest.mark.parametrize( "input_bom", [ - pytest.param(large_bom_2301, id="large_bom"), - pytest.param(sample_sustainability_bom_2301, id="sustainability_bom"), + pytest.param(large_bom_2301_path, id="large_bom"), + pytest.param(sample_sustainability_bom_2301_path, id="sustainability_bom"), ], ) - def test_roundtrip_parsing_succeeds(self, input_bom: str): + def test_roundtrip_from_file_parsing_succeeds(self, input_bom: str): bom_handler = BoMHandler() - deserialized_bom = bom_handler.load_bom_from_text(input_bom) + deserialized_bom = bom_handler.load_bom_from_file(input_bom) rendered_bom = bom_handler.dump_bom(deserialized_bom) deserialized_bom_roundtriped = bom_handler.load_bom_from_text(rendered_bom) @@ -175,7 +202,7 @@ def get_field(self, obj: bom_types.BaseType, p_path: str) -> Any: ("components[0]/components[1]/quantity/value", pytest.approx(1.0)), ("components[0]/components[1]/mass_per_unit_of_measure/unit", "kg/Part"), ("components[0]/components[1]/mass_per_unit_of_measure/value", pytest.approx(2.0)), - ("components[0]/components[1]/part_number", "3333"), + ("components[0]/components[1]/part_number", ""), ("components[0]/components[1]/part_name", "Part Two"), ("components[0]/components[1]/materials[0]/percentage", pytest.approx(80.0)), ("components[0]/components[1]/materials[0]/mi_material_reference/db_key", "MI_Restricted_Substances"),