Coverage for src/lumen/fhir/xml.py: 87%

45 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-08-31 18:46 +0000

1"""FHIR XML serialization/deserialization helpers. 

2 

3Wraps fhir.resources XML methods so the rest of the codebase 

4never imports fhir.resources directly (per project rules). 

5""" 

6 

7from __future__ import annotations 

8 

9import decimal 

10import importlib 

11import json 

12from typing import Any 

13 

14import lxml.etree as _lxml_etree # type: ignore[import-untyped] 

15 

16_etree: Any = _lxml_etree 

17 

18 

19def _decimal_to_xml_text(val: decimal.Decimal) -> str: 

20 """Render a decimal for XML the same way Lumen's JSON path renders it. 

21 

22 fhir_core's ``xml_represent`` coerces every ``Decimal`` through ``float()`` 

23 then ``str()``, so an integer-valued ``Decimal('20')`` serializes as ``20.0`` 

24 — mismatching the JSON output (``20``) and failing the suite's exact-equality 

25 FHIRPath checks. Naively using ``str(Decimal)`` instead fixes the integers but 

26 breaks fractions: values that arrive as JSON numbers are parsed to ``float``, 

27 and a ``Decimal`` built from such a float (e.g. ``52.52``) carries binary 

28 noise (``52.5200000…``) that ``str`` would emit verbatim. 

29 

30 Since Lumen already stores/serializes these via ``float`` on the JSON side, 

31 the XML side must match that exactly: integer-valued → no decimal point; 

32 otherwise Python's shortest round-tripping ``repr`` (identical to what 

33 ``json.dumps`` uses). Genuine trailing-zero precision cannot be preserved 

34 here regardless — it is already lost when the JSON number is parsed to float 

35 upstream, so both output formats agree. 

36 """ 

37 f = float(val) 

38 if f.is_integer(): 

39 return str(int(f)) 

40 return repr(f) 

41 

42 

43def _patch_decimal_xml_serialization() -> None: 

44 """Make fhir_core's XML decimal output match Lumen's JSON output. 

45 

46 Wraps ``xml_represent`` at our sole fhir.resources boundary (this module) so 

47 ``Decimal`` values render via `_decimal_to_xml_text`; everything else is 

48 delegated unchanged. Idempotent. 

49 """ 

50 from fhir_core import xml_utils # type: ignore[import-untyped] 

51 

52 xml_utils_any: Any = xml_utils 

53 if getattr(xml_utils_any.xml_represent, "_lumen_decimal_patched", False): 

54 return 

55 

56 original: Any = xml_utils_any.xml_represent 

57 

58 def patched(type_: Any, val: Any, version_prefix: str = "") -> Any: 

59 if isinstance(val, decimal.Decimal): 

60 return _decimal_to_xml_text(val) 

61 return original(type_, val, version_prefix) 

62 

63 patched._lumen_decimal_patched = True # type: ignore[attr-defined] 

64 xml_utils_any.xml_represent = patched 

65 

66 

67def json_to_xml(data: dict[str, Any]) -> bytes: 

68 """Convert a FHIR resource dict to XML bytes (requires lxml).""" 

69 _patch_decimal_xml_serialization() 

70 resource_type: str | None = data.get("resourceType") # type: ignore[assignment] 

71 if not resource_type: 

72 raise ValueError("Missing resourceType in FHIR resource") 

73 mod = importlib.import_module(f"fhir.resources.R4B.{resource_type.lower()}") 

74 cls = getattr(mod, resource_type) 

75 resource = cls.model_validate(data) 

76 result: bytes = resource.model_dump_xml() 

77 return result 

78 

79 

80def xml_to_json(xml_bytes: bytes) -> dict[str, Any]: 

81 """Convert FHIR XML bytes to a resource dict (requires lxml).""" 

82 root: Any = _etree.fromstring(xml_bytes) 

83 tag: str = str(root.tag) 

84 resource_type: str = tag.split("}")[-1] if "}" in tag else tag 

85 mod = importlib.import_module(f"fhir.resources.R4B.{resource_type.lower()}") 

86 cls = getattr(mod, resource_type) 

87 resource = cls.model_validate_xml(xml_bytes) 

88 result: dict[str, Any] = resource.model_dump() 

89 return result 

90 

91 

92def xml_to_json_bytes(xml_bytes: bytes) -> bytes: 

93 """Convert FHIR XML bytes to UTF-8 JSON bytes.""" 

94 return json.dumps(xml_to_json(xml_bytes)).encode()