Files
alfred/alfred/infrastructure/persistence/dot_alfred/v2/serializer.py
T
francwa e65c1df229 feat(.alfred v2 — Phase 2): Pydantic sidecars, atomic repos, auto-heal index
Spec: specs/dot_alfred_v2.md (Phase 2).

New package alfred/infrastructure/persistence/dot_alfred/v2/:
  * sidecar_release.py / sidecar_root.py — Pydantic DTOs
    (extra="forbid", frozen=True) for per-item sidecars and the
    library-root index. schema_version enforced via model_validator.
  * serializer.py — read_yaml / atomic_write_yaml (.tmp + os.replace).
    SidecarSchemaError wraps YAML + Pydantic errors uniformly.
  * bridge.py — lossless domain <-> sidecar for SeriesRelease /
    MovieRelease; projection-only show_index_entry_from /
    movie_index_entry_from with multi-episode-file flattening.
  * repository.py — DotAlfredSeriesReleaseRepository /
    DotAlfredMovieReleaseRepository (log+skip on corruption),
    DotAlfredTVShowLibraryIndex / DotAlfredMovieLibraryIndex with
    silent auto-heal on missing/corrupt index reads. Writes never
    auto-heal (read paths handle that).

TMDB client extensions:
  * TmdbSeasonInfo / TmdbShowInfo DTOs + pure parse_tv_show_info.
  * TMDBClient.get_tv_show_info aggregates /tv/{id} +
    /tv/{id}/external_ids.

Domain change:
  * SubtitleTrack gains is_sdh: bool = False, populated from
    ffprobe's hearing_impaired disposition. Required for v2 sidecar
    parity (spec replaces v1's type: "sdh" with explicit flag).
    Default keeps every existing caller unchanged.

Tests: 37 new v2 integration tests on tmp_path (round-trips, atomic
writes, schema mismatch handling, anchor warnings, auto-heal paths)
plus 16 TMDB DTO tests. Full suite: 1240 -> 1277 passed.

Implementation notes filed in .claude/specs/dot_alfred_v2_notes.md
(strict=True trade-off, upsert signature deviation from spec, etc.).

Phases 3-5 (TVShow/Movie refactor to TMDB-only, rescan_show rewrite,
v1 deletion + wiring) are next.
2026-05-25 16:01:39 +02:00

77 lines
2.3 KiB
Python

"""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)