Add batch photo processing with mode-specific LLM sessions and metadata writes
Tests / test_and_lint (push) Successful in 52s

This commit is contained in:
2026-10-18 09:04:36 +02:00
parent ff588f899e
commit c7de53f734
8 changed files with 415 additions and 7 deletions
+30
View File
@@ -1,5 +1,6 @@
import shutil
from pathlib import Path
from unittest.mock import patch
import pytest
@@ -211,3 +212,32 @@ def test_set_keywords(make_dng_file, pic, expected_keywords):
def test_set_keywords_on_existing():
# TODO
...
def test_write_caption_and_keywords_in_one_metadata_update(tmp_path):
writer = MetadataWriter(tmp_path / "photo.jpg")
with (
patch.object(writer, "get_caption", return_value="Original caption"),
patch.object(writer, "get_keywords", return_value=["existing"]),
patch.object(writer, "set_metadata") as set_metadata,
):
writer.write(caption="Generated caption", keywords=["architektur"])
set_metadata.assert_called_once()
metadata_tags = set_metadata.call_args.args[0]
assert metadata_tags["XMP:Description"] == (
f"Original caption. {PREFIX}Generated caption"
)
assert set(metadata_tags["XMP:Subject"]) == {
"existing",
f"{PREFIX}architektur",
}
def test_set_metadata_surfaces_write_failures(tmp_path):
writer = MetadataWriter(tmp_path / "photo.jpg")
with patch("photools3.metadata_writer.exiftool.ExifToolHelper") as exif:
exif_instance = exif.return_value.__enter__.return_value
exif_instance.execute.side_effect = OSError("ExifTool failed")
with pytest.raises(RuntimeError, match="Could not write metadata"):
writer.set_metadata({"XMP:Description": "caption"})
+9
View File
@@ -48,6 +48,15 @@ def test_load_photo():
assert isinstance(photo.image, Image.Image)
def test_photo_context_manager_closes_image(tmp_path):
path = _make_image_file(tmp_path, "RGB")
with Photo(path) as photo:
image = photo.image
assert image.fp is not None
assert image.fp is None
def test_resize_method_exists():
"""Test that resize method exists and returns a Photo instance"""
photo = Photo(PATHS_DNG_EXAMPLES[0])
+129
View File
@@ -0,0 +1,129 @@
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from PIL import Image
from photools3.models import (
LlmResponseCaption,
LlmResponseCaptionsAndKeywords,
LlmResponseKeywords,
)
from photools3.processing import (
ProcessingMode,
_create_session,
process_batch,
process_photo,
)
@pytest.fixture
def image_path(tmp_path):
path = tmp_path / "photo.png"
Image.new("RGB", (20, 20)).save(path)
return path
@pytest.mark.parametrize(
("mode", "response", "expected_metadata"),
[
(
ProcessingMode.KEYWORDS,
LlmResponseKeywords(tags=["architektur"]),
{"caption": None, "keywords": ["architektur"]},
),
(
ProcessingMode.CAPTIONS,
LlmResponseCaption(caption="Ein Haus"),
{"caption": "Ein Haus", "keywords": None},
),
(
ProcessingMode.BOTH,
LlmResponseCaptionsAndKeywords(caption="Ein Haus", tags=["architektur"]),
{"caption": "Ein Haus", "keywords": ["architektur"]},
),
],
)
def test_process_photo_writes_only_requested_metadata(
image_path, mode, response, expected_metadata
):
session = MagicMock()
session.call.return_value = response
with (
patch("photools3.processing._create_session", return_value=session),
patch("photools3.processing.MetadataWriter") as writer_class,
):
result = process_photo(image_path, mode)
assert result == response
session.call.assert_called_once()
session.close.assert_called_once()
writer_class.assert_called_once_with(image_path)
writer_class.return_value.write.assert_called_once_with(**expected_metadata)
def test_process_photo_does_not_close_a_caller_owned_session(image_path):
session = MagicMock()
session.call.return_value = LlmResponseCaption(caption="Ein Haus")
with patch("photools3.processing.MetadataWriter"):
process_photo(image_path, ProcessingMode.CAPTIONS, session=session)
session.close.assert_not_called()
@pytest.mark.parametrize(
("mode", "constructor"),
[
(ProcessingMode.KEYWORDS, "LlmSessionKeywords"),
(ProcessingMode.CAPTIONS, "LlmSessionCaption"),
(ProcessingMode.BOTH, "LlmSessionCaptionsAndKeywords"),
],
)
def test_create_session_selects_mode_specific_response_model(mode, constructor):
with patch(f"photools3.processing.{constructor}") as session_class:
assert _create_session(mode) is session_class.return_value
session_class.assert_called_once_with()
def test_process_batch_is_sequential_reuses_session_and_continues_after_error():
active_photos = 0
max_active_photos = 0
class TrackingPhoto:
def __init__(self, path):
self.path = path
def __enter__(self):
nonlocal active_photos, max_active_photos
active_photos += 1
max_active_photos = max(max_active_photos, active_photos)
return self
def __exit__(self, exc_type, exc_value, traceback):
nonlocal active_photos
active_photos -= 1
session = MagicMock()
response = LlmResponseCaptionsAndKeywords(caption="Ein Haus", tags=["architektur"])
session.call.side_effect = [response, RuntimeError("temporary error"), response]
paths = [Path("one.jpg"), Path("two.jpg"), Path("three.jpg")]
with (
patch("photools3.processing._create_session", return_value=session),
patch("photools3.processing.Photo", TrackingPhoto),
patch("photools3.processing.MetadataWriter") as writer_class,
):
outcomes = process_batch(paths, ProcessingMode.BOTH)
assert [outcome.succeeded for outcome in outcomes] == [True, False, True]
assert [outcome.path for outcome in outcomes] == paths
assert outcomes[1].error == "RuntimeError: temporary error"
assert max_active_photos == 1
assert active_photos == 0
assert session.call.call_count == 3
assert all(len(call.args[0]) == 1 for call in session.call.call_args_list)
assert writer_class.return_value.write.call_count == 2
session.close.assert_called_once()