Files
gnommo/gnommo/state.py
T
2026-07-15 20:00:04 +02:00

150 lines
5.1 KiB
Python

"""Persistent per-stage completion tracking.
Each pipeline stage (preprocess, trim, render) records a fingerprint of
its inputs in ``.gnommo_state.json`` when it completes successfully. On the next
run a stage can ask whether its inputs are unchanged (and its output still
present) and skip the work — the same staleness intelligence that ``all``'s
in-memory cascade provides, but persisted so it also applies to stages run on
their own.
Fingerprinting is hybrid:
- small text manifests (narration.json, videos.json, manuscript.txt,
project.json, slides.json, audio.json, transcripts) are hashed (sha256) so a
``touch`` or a git checkout that only rewrites mtimes doesn't force a
needless rerun;
- large media (processed narration segments, source videos and
images) use mtime+size, which is cheap and good enough to detect real edits.
The state file is purely an optimization: any read/parse/write failure degrades
to "not current" (rerun) and never raises, so a corrupt or missing state file
can't break a build.
"""
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable, Optional, Union
STATE_FILENAME = ".gnommo_state.json"
STATE_VERSION = 1
# Fingerprint modes
HASH = "hash" # sha256 of file contents — for small text manifests
META = "meta" # mtime_ns + size — for large media
# An input descriptor is a (label, path, mode) triple.
InputSpec = tuple[str, Path, str]
def _state_path(project_path: Path) -> Path:
return project_path / STATE_FILENAME
def _empty_state() -> dict:
return {"version": STATE_VERSION, "stages": {}}
def load_state(project_path: Path) -> dict:
"""Load the state file, returning an empty skeleton on any problem."""
path = _state_path(project_path)
if not path.exists():
return _empty_state()
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return _empty_state()
if not isinstance(data, dict):
return _empty_state()
data.setdefault("version", STATE_VERSION)
if not isinstance(data.get("stages"), dict):
data["stages"] = {}
return data
def save_state(project_path: Path, state: dict) -> None:
"""Write the state file. Never raises — state is best-effort."""
try:
_state_path(project_path).write_text(
json.dumps(state, indent=2) + "\n", encoding="utf-8"
)
except OSError:
pass
def fingerprint_path(path: Union[str, Path], mode: str) -> Optional[str]:
"""Return a fingerprint for a single file, or None if it can't be read."""
p = Path(path)
try:
if mode == HASH:
h = hashlib.sha256()
with open(p, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
h.update(chunk)
return f"sha256:{h.hexdigest()}"
st = p.stat()
return f"meta:{st.st_mtime_ns}:{st.st_size}"
except OSError:
return None
def compute(inputs: Iterable[InputSpec]) -> dict:
"""Build a {label: fingerprint} map from (label, path, mode) triples.
A missing file yields a null fingerprint, so a file appearing or disappearing
counts as a change.
"""
return {label: fingerprint_path(path, mode) for label, path, mode in inputs}
def get_stage(project_path: Path, stage_key: str) -> dict:
"""Return the recorded record for a stage (``{}`` if none)."""
return load_state(project_path).get("stages", {}).get(stage_key, {})
def get_items(project_path: Path, stage_key: str) -> dict:
"""Return the per-item fingerprint map recorded for a stage (``{}`` if none)."""
items = get_stage(project_path, stage_key).get("items")
return items if isinstance(items, dict) else {}
def is_current(
project_path: Path,
stage_key: str,
inputs: dict,
outputs: Iterable[Union[str, Path]] = (),
) -> bool:
"""True iff the recorded input fingerprint matches ``inputs`` exactly and
every output in ``outputs`` exists on disk."""
for out in outputs:
if not Path(out).exists():
return False
recorded = get_stage(project_path, stage_key).get("inputs")
return recorded == inputs
def record(project_path: Path, stage_key: str, inputs: dict) -> None:
"""Persist a stage-level input fingerprint, marking the stage complete."""
state = load_state(project_path)
state.setdefault("stages", {})[stage_key] = {
"completed_at": datetime.now(timezone.utc).isoformat(),
"inputs": inputs,
}
save_state(project_path, state)
def record_items(project_path: Path, stage_key: str, items: dict) -> None:
"""Merge per-item fingerprints into a stage's record (for multi-segment
stages like preprocess/trim). Existing items for other keys are preserved."""
state = load_state(project_path)
stage = state.setdefault("stages", {}).setdefault(stage_key, {})
stage["completed_at"] = datetime.now(timezone.utc).isoformat()
merged = stage.get("items")
if not isinstance(merged, dict):
merged = {}
merged.update(items)
stage["items"] = merged
save_state(project_path, state)