From 6d98ecf37558876d1dadde5130612f53c8c393ff Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 24 Jun 2026 10:33:23 +0000 Subject: [PATCH] Tolerate non-string building-part identifier (fix TypeError) A SAP-16.x cohort cert (9258-4062-7265-2844-7954) lodges a bare int building- part identifier (the second part as `1` after "Main Dwelling"). `BuildingPartIdentifier.from_api_string` regex-matched it assuming a string and raised "TypeError: expected string or bytes-like object, got 'int'", failing the whole property. Fix: guard the match on `isinstance(api_identifier, str)` so a non-string identifier falls to OTHER, matching the documented "anything unrecognised -> OTHER" contract. The baseline SAP fabric sums all building parts regardless of identifier, so OTHER is SAP-neutral; the identifier only labels parts for measure targeting. Fixes every mapper (all route through from_api_string). Cert now maps + calculates (sap 63). Regression test added. Co-Authored-By: Claude Opus 4.8 (1M context) --- datatypes/epc/domain/epc_property_data.py | 11 +++++--- .../domain/test_building_part_identifier.py | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 tests/datatypes/epc/domain/test_building_part_identifier.py diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index a49010d3..17437f6f 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -35,15 +35,18 @@ class BuildingPartIdentifier(Enum): OTHER = "other" @classmethod - def from_api_string(cls, api_identifier: Optional[str]) -> "BuildingPartIdentifier": + def from_api_string( + cls, api_identifier: Optional[object] + ) -> "BuildingPartIdentifier": """Map a gov-EPC API `BuildingPart.identifier` to its canonical member. "Main Dwelling" → MAIN; "Extension N" → EXTENSION_N - (for N in 1..4). `None` (permitted by the 21_0_1 schema) and - anything unrecognised fall to OTHER. + (for N in 1..4). `None` (permitted by the 21_0_1 schema), a non-string + identifier (some SAP-16.x certs lodge a bare int), and anything else + unrecognised fall to OTHER. """ if api_identifier == "Main Dwelling": return cls.MAIN - if api_identifier is not None: + if isinstance(api_identifier, str): match = _API_EXTENSION.match(api_identifier) if match is not None: return cls.extension(int(match.group(1))) diff --git a/tests/datatypes/epc/domain/test_building_part_identifier.py b/tests/datatypes/epc/domain/test_building_part_identifier.py new file mode 100644 index 00000000..d792f45a --- /dev/null +++ b/tests/datatypes/epc/domain/test_building_part_identifier.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier + + +def test_main_dwelling_maps_to_main() -> None: + assert BuildingPartIdentifier.from_api_string("Main Dwelling") == ( + BuildingPartIdentifier.MAIN + ) + + +def test_extension_string_maps_to_numbered_extension() -> None: + assert BuildingPartIdentifier.from_api_string("Extension 2") == ( + BuildingPartIdentifier.EXTENSION_2 + ) + + +def test_none_falls_to_other() -> None: + assert BuildingPartIdentifier.from_api_string(None) == BuildingPartIdentifier.OTHER + + +def test_non_string_identifier_falls_to_other_not_crash() -> None: + # Some SAP-16.x certs lodge a bare int building-part identifier (e.g. the + # second part as `1` after "Main Dwelling"). This previously raised + # `TypeError: expected string or bytes-like object, got 'int'` and failed + # the whole property — now it falls to OTHER like any unrecognised value. + assert BuildingPartIdentifier.from_api_string(1) == BuildingPartIdentifier.OTHER