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
« prev ^ index » next coverage.py v7.16.0, created at 2026-08-31 18:46 +0000
1"""FHIR XML serialization/deserialization helpers.
3Wraps fhir.resources XML methods so the rest of the codebase
4never imports fhir.resources directly (per project rules).
5"""
7from __future__ import annotations
9import decimal
10import importlib
11import json
12from typing import Any
14import lxml.etree as _lxml_etree # type: ignore[import-untyped]
16_etree: Any = _lxml_etree
19def _decimal_to_xml_text(val: decimal.Decimal) -> str:
20 """Render a decimal for XML the same way Lumen's JSON path renders it.
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.
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)
43def _patch_decimal_xml_serialization() -> None:
44 """Make fhir_core's XML decimal output match Lumen's JSON output.
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]
52 xml_utils_any: Any = xml_utils
53 if getattr(xml_utils_any.xml_represent, "_lumen_decimal_patched", False):
54 return
56 original: Any = xml_utils_any.xml_represent
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)
63 patched._lumen_decimal_patched = True # type: ignore[attr-defined]
64 xml_utils_any.xml_represent = patched
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
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
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()