48 lines
2.4 KiB
Python
48 lines
2.4 KiB
Python
"""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
|