Source code for materia_epd.epd.extraction_errors

"""Structured errors and XML context for EPD cache extraction."""

from __future__ import annotations

import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from pathlib import Path


[docs] @dataclass class EpdExtractionError(Exception): """Raised when a single EPD XML file cannot be extracted for the cache.""" process_path: str stage: str message: str process_uuid: str | None = None xml_tag: str | None = None xml_attributes: dict[str, str] = field(default_factory=dict) xml_line: int | None = None xml_text: str | None = None flow_path: str | None = None cause_type: str | None = None def __str__(self) -> str: return self.summary()
[docs] def summary(self) -> str: parts = [f"{Path(self.process_path).name} [{self.stage}] {self.message}"] if self.process_uuid: parts.append(f"uuid={self.process_uuid}") if self.xml_tag: parts.append(f"element={self.xml_tag}") if self.xml_attributes: attrs = " ".join(f'{k}="{v}"' for k, v in self.xml_attributes.items()) parts.append(f"attributes={attrs}") if self.xml_line is not None: parts.append(f"line={self.xml_line}") if self.xml_text is not None: parts.append(f"text={self.xml_text!r}") if self.flow_path: parts.append(f"flow={Path(self.flow_path).name}") if self.cause_type: parts.append(f"cause={self.cause_type}") return "; ".join(parts)
[docs] def to_log_dict(self) -> dict: payload = { "file": Path(self.process_path).name, "process_path": self.process_path, "stage": self.stage, "error": self.message, } if self.process_uuid: payload["uuid"] = self.process_uuid if self.xml_tag: payload["xml_tag"] = self.xml_tag if self.xml_attributes: payload["xml_attributes"] = self.xml_attributes if self.xml_line is not None: payload["xml_line"] = self.xml_line if self.xml_text is not None: payload["xml_text"] = self.xml_text if self.flow_path: payload["flow_path"] = self.flow_path if self.cause_type: payload["cause_type"] = self.cause_type payload["detail"] = self.summary() return payload
[docs] def local_tag(elem: ET.Element) -> str: """Return the local XML tag name without namespace URI.""" if "}" in elem.tag: return elem.tag.rsplit("}", 1)[-1] return elem.tag
[docs] def describe_element(elem: ET.Element | None) -> str | None: if elem is None: return None return local_tag(elem)
[docs] def element_attributes(elem: ET.Element | None) -> dict[str, str]: if elem is None: return {} return { key.rsplit("}", 1)[-1]: value for key, value in elem.attrib.items() if value is not None }
[docs] def find_element_line(xml_path: Path, elem: ET.Element | None) -> int | None: """ Best-effort line number lookup by scanning the source file for the element. ElementTree does not expose sourceline; we match tag name and salient text/attributes. """ if elem is None: return None tag = local_tag(elem) text = (elem.text or "").strip() attrib_values = [v for v in elem.attrib.values() if v] try: lines = xml_path.read_text(encoding="utf-8").splitlines() except OSError: return None candidates: list[int] = [] for lineno, line in enumerate(lines, 1): if tag not in line: continue if text and text not in line: continue if attrib_values and not any(value in line for value in attrib_values): continue candidates.append(lineno) if candidates: return candidates[0] for lineno, line in enumerate(lines, 1): if tag in line and ("<" in line or ":" + tag in line): return lineno return None
[docs] def wrap_extraction_error( exc: Exception, *, process_path: Path, stage: str, process_uuid: str | None = None, element: ET.Element | None = None, flow_path: Path | None = None, ) -> EpdExtractionError: """Convert an unexpected exception into a structured extraction error.""" if isinstance(exc, EpdExtractionError): return exc xml_line = find_element_line(process_path, element) return EpdExtractionError( process_path=str(process_path), stage=stage, message=str(exc), process_uuid=process_uuid, xml_tag=describe_element(element), xml_attributes=element_attributes(element), xml_line=xml_line, xml_text=(element.text or "").strip() if element is not None else None, flow_path=str(flow_path) if flow_path is not None else None, cause_type=type(exc).__name__, )