Skip to content

Commit

Permalink
Empty elements are no longer removed when deserializing a BoM file (#546
Browse files Browse the repository at this point in the history
)

Co-authored-by: pyansys-ci-bot <pyansys.github.bot@ansys.com>
  • Loading branch information
Andy-Grigg and pyansys-ci-bot committed Jun 3, 2024
1 parent e3920b3 commit 387019e
Show file tree
Hide file tree
Showing 5 changed files with 64 additions and 19 deletions.
1 change: 1 addition & 0 deletions doc/changelog.d/546.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Empty elements are no longer removed when deserializing a BoM file
23 changes: 20 additions & 3 deletions src/ansys/grantami/bomanalytics/_bom_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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.
Expand Down
12 changes: 6 additions & 6 deletions tests/inputs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
2 changes: 1 addition & 1 deletion tests/inputs/bom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
<Part>
<Quantity Unit="Each">1.0</Quantity>
<MassPerUom Unit="kg/Part">2.0</MassPerUom>
<PartNumber>3333</PartNumber>
<PartNumber />
<Name>Part Two</Name>
<Materials>
<Material>
Expand Down
45 changes: 36 additions & 9 deletions tests/test_bom_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"),
Expand Down

0 comments on commit 387019e

Please sign in to comment.