"""YAML I/O helpers for ``.alfred`` v2 sidecars. This module is intentionally thin: validation lives entirely in the Pydantic DTOs (``sidecar_release.py`` / ``sidecar_root.py``). Here we only do two things: * ``read_yaml`` — load text → dict, with friendly error translation. * ``atomic_write_yaml`` — render dict → ``.tmp`` file, then ``os.replace`` to the final path. Atomic on POSIX and NTFS, so no half-written file ever becomes visible to a concurrent reader. The bridge module is responsible for converting between domain aggregates and these DTOs; the repository module composes everything (walks the library, calls the bridge, persists via these helpers). """ from __future__ import annotations import os from pathlib import Path from typing import Any import yaml from .sidecar_release import SCHEMA_VERSION __all__ = [ "SCHEMA_VERSION", "SidecarSchemaError", "atomic_write_yaml", "read_yaml", ] class SidecarSchemaError(ValueError): """Raised when a sidecar file fails to load or validate. Wraps both YAML parse errors and Pydantic validation errors so callers can catch a single exception type and log + skip. """ def read_yaml(path: Path) -> dict[str, Any]: """Load a YAML file and return its top-level mapping. Raises :class:`SidecarSchemaError` if the file is unreadable, contains invalid YAML, or the top-level value is not a mapping (a list or scalar at the root is always a bug for our sidecars). """ try: text = path.read_text() except OSError as exc: raise SidecarSchemaError(f"cannot read {path}: {exc}") from exc try: data = yaml.safe_load(text) except yaml.YAMLError as exc: raise SidecarSchemaError(f"invalid YAML in {path}: {exc}") from exc if not isinstance(data, dict): raise SidecarSchemaError( f"{path}: top-level must be a mapping, got {type(data).__name__}" ) return data def atomic_write_yaml(path: Path, data: dict[str, Any]) -> None: """Atomically write ``data`` as YAML to ``path``. Uses the same ``write-tmp + os.replace`` pattern as v1: a reader racing with a writer either sees the previous version or the new one, never a torn file. """ tmp = path.with_suffix(path.suffix + ".tmp") tmp.write_text(yaml.safe_dump(data, sort_keys=False)) os.replace(tmp, path)