298 lines
11 KiB
Python
298 lines
11 KiB
Python
"""US07-03: EXIF checkpoints, asserted with before/after metadata snapshots.
|
|
|
|
Every test here reads the complete metadata of a real file through exiftool before
|
|
the stage runs and again afterwards, then compares the two. That is the only way to
|
|
prove the property the concept actually asks for: a stage owns a few fields and must
|
|
leave literally everything else — dates, GPS, camera, artist, rating, other people's
|
|
keywords — exactly as it found them.
|
|
|
|
The other half is divergence. When something outside the stage's ownership does move,
|
|
the checkpoint must say so, refuse to call itself verified, and change nothing back:
|
|
a silent repair is how a library quietly loses the user's metadata.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from photo_pipeline.config import Config
|
|
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
|
|
from photo_pipeline.integrations import exiftool
|
|
from photo_pipeline.models import Asset, ExifProjection
|
|
from photo_pipeline.services import exif_checkpoint
|
|
from photo_pipeline.services.analysis import AnalysisService
|
|
from photo_pipeline.services.safety import SafetyService
|
|
from tests.fixtures.media_corpus import CASES_BY_ID, build_corpus
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
shutil.which("exiftool") is None, reason="exiftool not installed"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def env(tmp_path):
|
|
data = tmp_path / "data"
|
|
data.mkdir()
|
|
lib = tmp_path / "lib"
|
|
files = build_corpus(
|
|
lib,
|
|
ids=(
|
|
"user_exif",
|
|
"prior_safety_keyword",
|
|
"prior_analysis_keywords",
|
|
"conflicting_safety_keywords",
|
|
"malformed_metadata",
|
|
"no_exif",
|
|
),
|
|
)
|
|
config = Config.from_env(
|
|
{"PHOTO_PIPELINE_DATA_DIR": str(data), "PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib)}
|
|
)
|
|
run_migrations(config.database_url)
|
|
engine = create_db_engine(config.database_url)
|
|
sf = create_session_factory(engine)
|
|
assets = {}
|
|
with sf() as session:
|
|
for case_id, path in files.items():
|
|
asset = Asset(
|
|
id=str(uuid.uuid4()),
|
|
original_path=str(path),
|
|
current_path=str(path),
|
|
discovered_at=datetime.now(timezone.utc),
|
|
hash_version=1,
|
|
)
|
|
session.add(asset)
|
|
assets[case_id] = asset.id
|
|
session.commit()
|
|
yield SimpleNamespace(config=config, lib=lib, sf=sf, files=files, assets=assets)
|
|
engine.dispose()
|
|
|
|
|
|
class Provider:
|
|
"""A vision provider whose tags are fixed, so the EXIF assertion is the test."""
|
|
|
|
def __init__(self, tags):
|
|
self.tags = list(tags)
|
|
|
|
def analyze(self, path, *, album_hint):
|
|
return {"description": "a photo", "tags": self.tags}
|
|
|
|
|
|
def snapshot(path):
|
|
return exiftool.read_all(str(path))
|
|
|
|
|
|
def stage_fields(before, after):
|
|
"""Everything that changed except this application's owned and volatile tags."""
|
|
return exif_checkpoint.compare(before, after)
|
|
|
|
|
|
# ── preservation ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_a_safety_decision_preserves_every_user_field(env):
|
|
case = CASES_BY_ID["user_exif"]
|
|
path = env.files["user_exif"]
|
|
before = snapshot(path)
|
|
assert before, "the fixture must actually carry user metadata"
|
|
|
|
result = SafetyService(env.sf).decide(env.assets["user_exif"], "nsfw")
|
|
after = snapshot(path)
|
|
|
|
assert result["exif_verified"] is True
|
|
assert stage_fields(before, after) == ()
|
|
for field in case.preserved_fields:
|
|
assert after[field] == before[field], field
|
|
assert "nsfw" in exif_checkpoint.owned_values(after)
|
|
assert "sfw" not in exif_checkpoint.owned_values(after)
|
|
|
|
|
|
def test_analysis_keywords_are_additive_and_keep_the_safety_decision(env):
|
|
"""The two stages share the Keywords field; the second must merge, not replace."""
|
|
asset_id = env.assets["prior_analysis_keywords"]
|
|
path = env.files["prior_analysis_keywords"]
|
|
SafetyService(env.sf).decide(asset_id, "sfw")
|
|
before = snapshot(path)
|
|
|
|
AnalysisService(
|
|
env.sf, provider=Provider(["harbour", "boats"]), library_roots=(env.lib,)
|
|
).run([asset_id])
|
|
after = snapshot(path)
|
|
|
|
keywords = exif_checkpoint.owned_values(after)
|
|
assert {"sfw", "beach", "sunset", "harbour", "boats"} <= keywords
|
|
assert "nsfw" not in keywords
|
|
assert stage_fields(before, after) == ()
|
|
|
|
|
|
def test_flipping_a_safety_decision_removes_only_the_opposite_keyword(env):
|
|
asset_id = env.assets["prior_analysis_keywords"]
|
|
path = env.files["prior_analysis_keywords"]
|
|
safety = SafetyService(env.sf)
|
|
safety.decide(asset_id, "nsfw")
|
|
before = snapshot(path)
|
|
|
|
safety.decide(asset_id, "sfw")
|
|
after = snapshot(path)
|
|
|
|
keywords = exif_checkpoint.owned_values(after)
|
|
assert "sfw" in keywords and "nsfw" not in keywords
|
|
assert {"beach", "sunset"} <= keywords, "analysis keywords are not safety's to remove"
|
|
assert stage_fields(before, after) == ()
|
|
|
|
|
|
def test_conflicting_safety_keywords_are_resolved_to_one(env):
|
|
asset_id = env.assets["conflicting_safety_keywords"]
|
|
SafetyService(env.sf).decide(asset_id, "sfw")
|
|
keywords = exif_checkpoint.owned_values(snapshot(env.files["conflicting_safety_keywords"]))
|
|
assert keywords & {"sfw", "nsfw"} == {"sfw"}
|
|
|
|
|
|
def test_malformed_metadata_does_not_block_the_checkpoint(env):
|
|
result = SafetyService(env.sf).decide(env.assets["malformed_metadata"], "nsfw")
|
|
assert result["exif_verified"] is True
|
|
|
|
|
|
# ── verification and the refreshed hash ───────────────────────────────────────
|
|
|
|
|
|
def test_a_verified_checkpoint_refreshes_the_recorded_bytes(env):
|
|
"""exiftool rewrites the container, so the stored SHA-256 must be the new one —
|
|
upload compares against exactly these bytes."""
|
|
asset_id = env.assets["no_exif"]
|
|
path = env.files["no_exif"]
|
|
SafetyService(env.sf).decide(asset_id, "sfw")
|
|
|
|
with env.sf() as session:
|
|
asset = session.get(Asset, asset_id)
|
|
row = session.get(ExifProjection, (asset_id, "safety"))
|
|
import hashlib
|
|
|
|
on_disk = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
assert asset.current_sha256 == on_disk
|
|
assert row.state == "verified" and row.result_file_sha256 == on_disk
|
|
assert json.loads(row.desired_json) == {"add": ["sfw"], "remove": ["nsfw"]}
|
|
|
|
|
|
def test_the_projection_survives_a_restart(env):
|
|
asset_id = env.assets["user_exif"]
|
|
SafetyService(env.sf).decide(asset_id, "nsfw")
|
|
|
|
engine = create_db_engine(env.config.database_url) # a fresh connection, as a restart is
|
|
try:
|
|
with create_session_factory(engine)() as session:
|
|
row = session.get(ExifProjection, (asset_id, "safety"))
|
|
assert row.state == "verified" and row.verified_at is not None
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
# ── divergence ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_a_field_the_stage_does_not_own_changing_is_divergent(env, monkeypatch):
|
|
"""Something rewrote the artist while the safety keyword was being written.
|
|
|
|
The stage must not call that verified, must record what moved, and must not put
|
|
the old value back — the file is now a question for a human.
|
|
"""
|
|
asset_id = env.assets["user_exif"]
|
|
path = env.files["user_exif"]
|
|
real_apply = exiftool.apply_keywords
|
|
|
|
def sabotage(target, *, add=(), remove=()):
|
|
ok = real_apply(target, add=add, remove=remove)
|
|
subprocess.run(
|
|
["exiftool", "-m", "-overwrite_original", "-Artist=Someone Else", str(target)],
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
return ok
|
|
|
|
monkeypatch.setattr(exiftool, "apply_keywords", sabotage)
|
|
result = SafetyService(env.sf).decide(asset_id, "nsfw")
|
|
|
|
assert result["exif_verified"] is False, "a divergent checkpoint is not verified"
|
|
with env.sf() as session:
|
|
row = session.get(ExifProjection, (asset_id, "safety"))
|
|
asset = session.get(Asset, asset_id)
|
|
assert row.state == "divergent"
|
|
assert "EXIF:IFD0:Artist" in json.loads(row.divergent_fields)
|
|
assert row.verified_at is None
|
|
# Not repaired, and not silently accepted as the current verified bytes.
|
|
assert snapshot(path)["EXIF:IFD0:Artist"] == "Someone Else"
|
|
assert asset.current_sha256 is None
|
|
|
|
|
|
def test_a_divergent_asset_shows_up_in_the_review_queue(env, monkeypatch):
|
|
asset_id = env.assets["user_exif"]
|
|
monkeypatch.setattr(
|
|
exif_checkpoint,
|
|
"run",
|
|
lambda *args, **kwargs: exif_checkpoint.CheckpointResult(
|
|
exif_checkpoint.DIVERGENT, changed_fields=("EXIF:IFD0:Artist",), sha256="abc"
|
|
),
|
|
)
|
|
SafetyService(env.sf).decide(asset_id, "sfw")
|
|
|
|
rows = SafetyService(env.sf).review_queue()["items"]
|
|
row = next(item for item in rows if item["asset_id"] == asset_id)
|
|
assert row["exif_state"] == "divergent"
|
|
assert row["exif_verified"] is False
|
|
|
|
|
|
def test_a_write_that_does_not_take_is_a_failure_not_a_verification(env, monkeypatch):
|
|
monkeypatch.setattr(exiftool, "apply_keywords", lambda *a, **k: False)
|
|
result = SafetyService(env.sf).decide(env.assets["no_exif"], "sfw")
|
|
assert result["exif_verified"] is False
|
|
assert exif_checkpoint.state_for(env.sf, env.assets["no_exif"], "safety") == "failed"
|
|
|
|
|
|
def test_unreadable_metadata_is_a_failure_not_an_empty_snapshot(env, monkeypatch):
|
|
"""``None`` from exiftool means "cannot answer"; treating it as "nothing there"
|
|
would make every field look preserved."""
|
|
monkeypatch.setattr(exiftool, "read_all", lambda path: None)
|
|
result = exif_checkpoint.run(str(env.files["no_exif"]), add=("sfw",))
|
|
assert result.state == "failed" and result.reason == "metadata_unreadable"
|
|
|
|
|
|
def test_analysis_records_its_own_projection_separately(env):
|
|
asset_id = env.assets["user_exif"]
|
|
SafetyService(env.sf).decide(asset_id, "sfw")
|
|
AnalysisService(env.sf, provider=Provider(["pier"]), library_roots=(env.lib,)).run([asset_id])
|
|
|
|
assert exif_checkpoint.state_for(env.sf, asset_id, "safety") == "verified"
|
|
assert exif_checkpoint.state_for(env.sf, asset_id, "analysis") == "verified"
|
|
|
|
|
|
# ── the comparison rules themselves ───────────────────────────────────────────
|
|
|
|
|
|
def test_compare_ignores_owned_and_volatile_fields_only():
|
|
before = {
|
|
"EXIF:IFD0:Artist": "Ada",
|
|
"IPTC:Keywords": ["holiday"],
|
|
"File:System:FileSize": "3.8 kB",
|
|
"File:CurrentIPTCDigest": "aaa",
|
|
"XMP:XMP-x:XMPToolkit": "old",
|
|
}
|
|
after = {
|
|
"EXIF:IFD0:Artist": "Ada",
|
|
"IPTC:Keywords": ["holiday", "sfw"],
|
|
"File:System:FileSize": "3.9 kB",
|
|
"File:CurrentIPTCDigest": "bbb",
|
|
"XMP:XMP-x:XMPToolkit": "new",
|
|
}
|
|
assert exif_checkpoint.compare(before, after) == ()
|
|
|
|
after["EXIF:GPS:GPSLatitude"] = "48.1" # an addition counts as much as a loss
|
|
del after["EXIF:IFD0:Artist"]
|
|
assert exif_checkpoint.compare(before, after) == ("EXIF:GPS:GPSLatitude", "EXIF:IFD0:Artist")
|