"""Shared YAML I/O helpers. All govtool modules read and write the constitution through these helpers so that error handling and serialisation behave identically everywhere: * :func:`load_yaml` raises :class:`govtool.errors.GovtoolError` (never a raw ``yaml.YAMLError`` or ``FileNotFoundError``) so callers can treat any failure uniformly. * :func:`dump_yaml` writes deterministic, human-diffable YAML: keys are kept in insertion order (``sort_keys=False``) because article and module files are *documents*, not just data, and reordering keys in a governance diff is noise. * :func:`to_jsonable` converts YAML-native types that JSON cannot represent (``datetime``, ``date``, sets) into stable string/list forms. Every content hash in the pipeline is computed over the jsonable view, so a timestamp parses to the same hash whether YAML auto-parsed it into a ``datetime`` or left it as a string. """ from __future__ import annotations import datetime as _dt from pathlib import Path from typing import Any import yaml from govtool.errors import GovtoolError class YamlIOError(GovtoolError): """Raised when a YAML file cannot be read, parsed, or written.""" def load_yaml(path: Path | str) -> Any: """Load a YAML file, raising :class:`YamlIOError` on any failure.""" path = Path(path) try: text = path.read_text(encoding="utf-8") except FileNotFoundError as exc: raise YamlIOError(f"file not found: {path}") from exc except OSError as exc: raise YamlIOError(f"cannot read {path}: {exc}") from exc try: return yaml.safe_load(text) except yaml.YAMLError as exc: raise YamlIOError(f"invalid YAML in {path}: {exc}") from exc def dump_yaml(data: Any, path: Path | str) -> None: """Write ``data`` as deterministic, human-diffable YAML.""" path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) try: text = yaml.safe_dump( data, sort_keys=False, default_flow_style=False, allow_unicode=True, width=88, ) except yaml.YAMLError as exc: # pragma: no cover - safe_dump rarely fails raise YamlIOError(f"cannot serialise data for {path}: {exc}") from exc try: path.write_text(text, encoding="utf-8") except OSError as exc: raise YamlIOError(f"cannot write {path}: {exc}") from exc def to_jsonable(node: Any) -> Any: """Recursively convert a YAML-loaded structure to JSON-safe types. * ``datetime``/``date`` become ISO-8601 strings (UTC-normalised when timezone-aware). * sets become sorted lists. * dict keys are coerced to strings. This is applied before any canonical hashing so hashes are stable regardless of how YAML chose to parse scalar values. """ if isinstance(node, dict): return {str(k): to_jsonable(v) for k, v in node.items()} if isinstance(node, (list, tuple)): return [to_jsonable(item) for item in node] if isinstance(node, set): return sorted(to_jsonable(item) for item in node) if isinstance(node, _dt.datetime): if node.tzinfo is not None: node = node.astimezone(_dt.timezone.utc) return node.isoformat() if isinstance(node, _dt.date): return node.isoformat() return node