188 lines
6.6 KiB
Python
188 lines
6.6 KiB
Python
"""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
|