"""Private, best-effort acquisition observations, separate from source evidence.""" from datetime import datetime, timezone from .capture_job import atomic_json, fingerprint VERSION = "arr-acquisition-progress/v1" PHASES = frozenset({"queued", "searching", "fetching", "verifying", "completed", "failed", "interrupted"}) PUBLIC_FIELDS = frozenset({"phase", "completed", "total", "percent", "updated_at"}) def snapshot(phase, completed=0, total=None, *, updated_at=None): percent = (100 if phase == "completed" else 0 if phase == "queued" else min(99, completed * 100 // total) if total else 99 if total == 0 else None) return validate({"phase": phase, "completed": completed, "total": total, "percent": percent, "updated_at": updated_at or datetime.now(timezone.utc).isoformat()}) def validate(value): """Return only the fixed privacy-safe public contract; reject malformed data.""" if type(value) is not dict or set(value) != PUBLIC_FIELDS or value["phase"] not in PHASES: raise ValueError("invalid acquisition progress") completed, total, percent = value["completed"], value["total"], value["percent"] if type(completed) is not int or not 0 <= completed <= 10000000: raise ValueError("invalid acquisition count") if total is not None and (type(total) is not int or not completed <= total <= 10000000): raise ValueError("invalid acquisition total") if percent is not None and (type(percent) is not int or not 0 <= percent <= 100): raise ValueError("invalid acquisition percent") if value["phase"] == "completed": if total is None or completed != total or percent != 100: raise ValueError("incomplete acquisition") elif percent == 100: raise ValueError("premature acquisition completion") stamp = value["updated_at"] if type(stamp) is not str or len(stamp) > 40 or datetime.fromisoformat(stamp).tzinfo is None: raise ValueError("invalid acquisition timestamp") return dict(value) def publish(folder, identity, phase, completed=0, total=None): """An observation failure must never change the source collection outcome.""" try: atomic_json(folder / "progress.json", {"version": VERSION, "identity_sha256": fingerprint(identity), "progress": snapshot(phase, completed, total)}, replace=True) except Exception: pass