"""The EXIF checkpoint every metadata stage ends with (concept §3, US07-03). A stage does not own a file's metadata; it owns a few fields in it. So writing is never "set these tags" — it is: snapshot everything → write only the owned fields → read everything back → prove the owned fields landed → prove nothing else moved → refresh the file hash → record the projection Non-destructive here means *semantic* preservation: exiftool may rewrite the whole container, so the file's bytes, size, and timestamps legitimately change. What may not change is any field this stage does not own. When one does, the checkpoint is ``divergent``: the result is recorded, the stage is **not** marked verified, and nothing is silently repaired — a later stage that needs verified metadata (upload) therefore stays blocked until a human looks. ``failed`` is the third outcome and is deliberately distinct: exiftool missing, an unreadable file, or a write that did not take is not evidence that metadata is fine. """ from __future__ import annotations import json import os import uuid from dataclasses import dataclass from datetime import datetime, timezone from photo_pipeline.faults import EXIF_WRITTEN, maybe_fault from photo_pipeline.integrations import exiftool from photo_pipeline.models import ExifProjection from photo_pipeline.services import hashing # The tags the safety and analysis stages may change. Matched on the tag name, so # every group carries the same rule (IPTC:Keywords, XMP:XMP-dc:Subject, ...). OWNED_TAGS = frozenset({"Keywords", "Subject"}) # Not metadata about the picture: filesystem facts, the digest that necessarily # moves whenever IPTC does, and the structural tags exiftool has to create the first # time it writes an IPTC or XMP block. Comparing these would report every write as # divergent and make the signal worthless. VOLATILE_PREFIXES = ("File:System:", "ExifTool:") VOLATILE_KEYS = frozenset( { "File:CurrentIPTCDigest", "IPTC:ApplicationRecordVersion", "XMP:XMP-x:XMPToolkit", "XMP:XMP-xmp:MetadataDate", } ) VERIFIED = "verified" DIVERGENT = "divergent" FAILED = "failed" @dataclass(frozen=True) class CheckpointResult: state: str # verified | divergent | failed changed_fields: tuple[str, ...] = () sha256: str | None = None # exiftool rewrites the container, so the file's size moves with its hash. Both # are inventory facts about the current bytes and both have to be refreshed # together, or the next stage compares against a size that no longer exists # (US07-07: a rename plan blocked itself forever after any EXIF write). byte_size: int | None = None verified_at: datetime | None = None reason: str | None = None @property def verified(self) -> bool: return self.state == VERIFIED def _now() -> datetime: return datetime.now(timezone.utc) def is_owned(key: str) -> bool: return key.rsplit(":", 1)[-1] in OWNED_TAGS def is_volatile(key: str) -> bool: return key in VOLATILE_KEYS or key.startswith(VOLATILE_PREFIXES) def compare(before: dict, after: dict) -> tuple[str, ...]: """Fields outside this stage's ownership whose value did not survive the write. Additions count: a tag that appears out of nowhere is as much a divergence as a tag that disappeared — both mean the write did more than it was asked to. """ keys = set(before) | set(after) return tuple( sorted( key for key in keys if not is_owned(key) and not is_volatile(key) and before.get(key) != after.get(key) ) ) def owned_values(snapshot: dict) -> set[str]: """Lowercased Keywords/Subject values across every group in the snapshot.""" values: set[str] = set() for key, value in snapshot.items(): if not is_owned(key): continue items = value if isinstance(value, list) else [value] values.update(str(item).strip().lower() for item in items if item is not None) return values def run( path: str, *, add: tuple[str, ...] = (), remove: tuple[str, ...] = (), ) -> CheckpointResult: """Write the owned keywords for one asset and verify the whole file around them.""" before = exiftool.read_all(path) if before is None: return CheckpointResult(FAILED, reason="metadata_unreadable") if not exiftool.apply_keywords(path, add=add, remove=remove): return CheckpointResult(FAILED, reason="write_failed") # The file on disk has changed; nothing about it is recorded yet. A crash here # is the worst case for metadata, so it is a fault control point (US07-04). maybe_fault(EXIF_WRITTEN) after = exiftool.read_all(path) if after is None: return CheckpointResult(FAILED, reason="readback_unreadable") present = owned_values(after) wanted = {value.strip().lower() for value in add} unwanted = {value.strip().lower() for value in remove} if not wanted <= present or (unwanted & present): return CheckpointResult(FAILED, reason="owned_fields_not_written") changed = compare(before, after) sha256 = hashing.sha256_file(path) byte_size = os.path.getsize(path) if changed: return CheckpointResult( DIVERGENT, changed_fields=changed, sha256=sha256, byte_size=byte_size ) return CheckpointResult(VERIFIED, sha256=sha256, byte_size=byte_size, verified_at=_now()) def record( session_factory, *, asset_id: str, stage: str, result: CheckpointResult, add: tuple[str, ...] = (), remove: tuple[str, ...] = (), ) -> None: """Persist the projection for ``(asset_id, stage)`` — one current row per pair. The row is what makes divergence durable and reviewable rather than a log line that scrolled away. """ with session_factory() as session: row = session.get(ExifProjection, (asset_id, stage)) if row is None: row = ExifProjection(asset_id=asset_id, stage=stage, id=str(uuid.uuid4())) session.add(row) row.projection_version = (row.projection_version or 0) + 1 row.desired_json = json.dumps({"add": list(add), "remove": list(remove)}) row.divergent_fields = json.dumps(list(result.changed_fields)) row.result_file_sha256 = result.sha256 row.state = result.state row.error_code = result.reason row.verified_at = result.verified_at row.updated_at = _now() session.commit() def state_for(session_factory, asset_id: str, stage: str) -> str | None: with session_factory() as session: row = session.get(ExifProjection, (asset_id, stage)) return row.state if row else None