From b9632d46b286f5e55bdee7c8e871be9d2681a54a Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 10 Oct 2026 12:22:15 +0000 Subject: [PATCH] Metadata writer (#17) Reviewed-on: https://gitea.schneiderserver.site/mathias2784/photools3/pulls/17 Co-authored-by: Mathias --- .env.template | 2 + .gitea/workflows/ci.yml | 9 ++ AGENTS.md | 5 + pyproject.toml | 1 + src/photools3/llm_session.py | 4 +- src/photools3/metadata_writer.py | 159 +++++++++++++++++++++++ src/photools3/settings.py | 5 + test/test_metadata_writer.py | 213 +++++++++++++++++++++++++++++++ test/test_settings.py | 4 +- uv.lock | 13 +- 10 files changed, 408 insertions(+), 7 deletions(-) create mode 100644 src/photools3/metadata_writer.py create mode 100644 test/test_metadata_writer.py diff --git a/.env.template b/.env.template index 69a0aea..39cc3c7 100644 --- a/.env.template +++ b/.env.template @@ -1,3 +1,5 @@ LLM_API_BASE_URL = "http://localhost:1234/v1" LLM_API_KEY = "not-relevant" LLM_DEFAULT_TEMPERATURE = 1.0 + +METADATA_PREFIX = "photools|" diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e8097c2..b0ff10b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -18,6 +18,15 @@ jobs: run: uv sync --frozen - name: copy env run: cp .env.template .env + - name: Install exiftool (system dependency) + run: | + if command -v exiftool >/dev/null 2>&1; then + echo "exiftool already installed" + elif command -v sudo >/dev/null 2>&1; then + sudo apt-get update && sudo apt-get install -y --no-install-recommends libimage-exiftool-perl + else + apt-get update && apt-get install -y --no-install-recommends libimage-exiftool-perl + fi - name: Run pytest run: uv run pytest . - name: Check formatting (black) diff --git a/AGENTS.md b/AGENTS.md index fd16e82..eb30161 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,11 @@ Requires a `.env` file with the following variables: - `llm_api_base_url`: Base URL for the LLM API. - `llm_default_temperature`: Float value for temperature. +## System Dependencies +- `exiftool` binary: required by `pyexiftool` at runtime, not installable via pip/uv. + - Arch: `perl-image-exiftool` + - Debian/Ubuntu: `libimage-exiftool-perl` + ## Development Toolchain - **Dependency Management**: Use `uv`. - **Python Version**: Requires Python `>=3.14`. diff --git a/pyproject.toml b/pyproject.toml index e004ca3..b758f24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "pillow>=12.3.0", "pydantic>=2.13.5", "pydantic-settings>=2.15.0", + "pyexiftool>=0.5.6", "pytest>=9.1.1", "ruff>=0.16.9", "typer>=0.12.0", diff --git a/src/photools3/llm_session.py b/src/photools3/llm_session.py index e3b1f4a..430e2c4 100644 --- a/src/photools3/llm_session.py +++ b/src/photools3/llm_session.py @@ -17,12 +17,10 @@ from photools3.models import ( LlmResponseQuality, ) from photools3.photo import Photo -from photools3.settings import Settings +from photools3.settings import settings logger = logging.getLogger(__name__) -settings = Settings() # type: ignore[reportCallIssue] - class LlmSession: base_url: str = str(settings.llm_api_base_url) diff --git a/src/photools3/metadata_writer.py b/src/photools3/metadata_writer.py new file mode 100644 index 0000000..7370838 --- /dev/null +++ b/src/photools3/metadata_writer.py @@ -0,0 +1,159 @@ +import logging +import shutil +from pathlib import Path +from typing import Annotated, Any + +import exiftool +from pydantic import StringConstraints + +from photools3.settings import settings + +log = logging.getLogger(__name__) + +_EXIFTOOL_MISSING_MSG = ( + "exiftool binary not found in PATH. " + "Install the system package: 'perl-image-exiftool' (Arch) or 'libimage-exiftool-perl' (Debian/Ubuntu)." +) +if shutil.which("exiftool") is None: + raise FileNotFoundError(_EXIFTOOL_MISSING_MSG) + +XmpKey = Annotated[str, StringConstraints(pattern=r"^\w+\:\w+")] + +type Caption = str +type Keywords = list[str] +type Color = int +type XmpMetadata = dict[str, Any] + +XMP_KEYS_CAPTION: list[XmpKey] = [ + "XMP:Description", + "EXIF:ImageDescription", + "IPTC:Caption-Abstract", +] + +XMP_KEYS_KEYWORDS: list[XmpKey] = [ + "XMP:HierarchicalSubject", + "XMP:Subject", + "IPTC:Keywords", +] + + +def _prefixed(txt: str) -> str: + return f"{settings.metadata_prefix}{txt}" + + +def _is_prefixed(txt: str) -> bool: + return txt.startswith(settings.metadata_prefix) + + +def _separate_prefixed_caption(txt: str) -> tuple[str, str | None]: + separated = [x.strip() for x in txt.split(settings.metadata_prefix)] + return (separated[0], separated[1] if len(separated) > 1 else None) + + +class MetadataWriter: + """ + Write metadata (captions and keywords) to image files. + For DNG files, creates/updates XMP sidecar files. + """ + + def __init__( + self, + path: Path, + force_sidecar: bool = False, + append: bool = True, + ): + self.path: Path = path + self.force_sidecar: bool = force_sidecar + self.append: bool = append + + self._metadata: XmpMetadata | None = None + + @property + def sidecar_path(self) -> Path: + return Path(str(self.path) + ".xmp") + + @property + def metadata(self) -> Any: + path = self.sidecar_path if self.sidecar_path.exists() else self.path + if not path.exists(): + log.warning(f"File not found: {path}") + return None + try: + with exiftool.ExifToolHelper() as exif: + metadata = exif.get_metadata([path])[0] + except Exception as exc: # noqa: BLE001 + log.error(f"Could not read metadata from {path}: {exc}") + return None + return metadata + + def get_caption(self) -> str: + result = self.metadata.get(XMP_KEYS_CAPTION[0], "") + assert isinstance(result, str) + return result + + def get_keywords(self) -> list[str]: + result = self.metadata.get(XMP_KEYS_KEYWORDS[0], []) or [] + if isinstance(result, str): + result = [result] + assert isinstance(result, list) + return result + + def set_metadata(self, metadata_tags: dict[str, Any]): + try: + with exiftool.ExifToolHelper() as exif: + if not self.sidecar_path.exists(): + exif.execute("-o", str(self.sidecar_path), str(self.path)) + exif.set_tags( + [str(self.sidecar_path)], + tags=metadata_tags, + params=["-overwrite_original"], + ) + except Exception as exc: # noqa: BLE001 + log.error(f"Could not write metadata to file {self.sidecar_path}: {exc}") + + def write_caption( + self, caption: Caption, include_prefix: bool = True, override: bool = True + ): + existing_no_prefix, existing_prefix = _separate_prefixed_caption( + self.get_caption() + ) + if existing_no_prefix: + existing_no_prefix = ( + f"{existing_no_prefix}." + if not existing_no_prefix.endswith(".") + else existing_no_prefix + ) + existing_no_prefix = f"{existing_no_prefix} " + + if include_prefix and existing_prefix and not override: + return + if not include_prefix and existing_no_prefix and not override: + return + if include_prefix: + caption = f"{existing_no_prefix}{_prefixed(caption)}" + metadata_tags = {key: caption for key in XMP_KEYS_CAPTION} + self.set_metadata(metadata_tags) + + def write_keywords(self, keywords: Keywords, include_prefix: bool = True): + if include_prefix: + keywords = list(map(_prefixed, keywords)) + existing = self.get_keywords() + keywords = list(set(existing + keywords)) + metadata_tags = {tag: keywords for tag in XMP_KEYS_KEYWORDS} + self.set_metadata(metadata_tags) + + def write_color(self, color: Color): + self.set_metadata({"XMP-darktable:Colorlabels": color}) + + def write( + self, + caption: Caption | None = None, + keywords: Keywords | None = None, + color: Color | None = None, + ): + if caption is not None: + self.write_caption(caption) + if keywords is not None: + self.write_keywords(keywords) + if color is not None: + self.write_color(color) diff --git a/src/photools3/settings.py b/src/photools3/settings.py index 3e0d1b4..22d65cb 100644 --- a/src/photools3/settings.py +++ b/src/photools3/settings.py @@ -18,3 +18,8 @@ class Settings(BaseSettings): llm_api_base_url: Url llm_api_key: str llm_default_temperature: float + + metadata_prefix: str + + +settings = Settings() # type: ignore[reportCallIssue] diff --git a/test/test_metadata_writer.py b/test/test_metadata_writer.py new file mode 100644 index 0000000..b2b96a6 --- /dev/null +++ b/test/test_metadata_writer.py @@ -0,0 +1,213 @@ +import shutil +from pathlib import Path + +import pytest + +from photools3.metadata_writer import ( + MetadataWriter, + _is_prefixed, + _prefixed, +) +from photools3.settings import settings +from test.test_photo import PATHS_DNG_EXAMPLES + +PREFIX = settings.metadata_prefix + + +@pytest.fixture +def make_dng_file(tmp_path): + def _make(src: Path) -> Path: + dst = tmp_path / f"copy_{src.name}" + shutil.copy(src, dst) + return dst + + return _make + + +@pytest.fixture +def metadata_writer_on_existing(make_dng_file): + metadata_writer = MetadataWriter(make_dng_file(PATHS_DNG_EXAMPLES[0])) + metadata_writer.write_caption( + "Let's pretend this is the caption of the original image", + include_prefix=False, + ) + return metadata_writer + + +@pytest.mark.parametrize("pic", PATHS_DNG_EXAMPLES) +def test_read_metadata(make_dng_file, pic): + """primitive test for `MetadataWriter`""" + file_path = make_dng_file(pic) + result = MetadataWriter(file_path) + assert result + assert result.metadata + assert isinstance(result.metadata, dict) + + +@pytest.mark.parametrize( + "pic, expected_keywords", + [ + (PATHS_DNG_EXAMPLES[0], ["architektur", "zeit|abend"]), + (PATHS_DNG_EXAMPLES[1], ["architektur"]), + (PATHS_DNG_EXAMPLES[2], ["morgen", "tiere|vögel"]), + ( + PATHS_DNG_EXAMPLES[3], + [ + "abend", + "architektur", + "bewegungsunschärfe", + "lichter", + "mainz", + "spiegelung", + "wasser", + ], + ), + ( + PATHS_DNG_EXAMPLES[4], + ["grau", "landschaft", "landschaft|weinberge", "personen", "tiefe", "weg"], + ), + ], +) +def test_get_keywords(make_dng_file, pic, expected_keywords): + path = make_dng_file(pic) + result = MetadataWriter(path).get_keywords() + assert sorted(result) == sorted(expected_keywords) + + +def test_prefixed(): + txt = "bla_bla" + txt_prefixed = _prefixed(txt) + assert txt_prefixed == f"{PREFIX}bla_bla" + + +@pytest.mark.parametrize( + "txt, expected_result", + [ + (f"{PREFIX}some text", True), + ("prefix is missing here", False), + ("some_perfix|but not the right one", False), + ], +) +def test_is_prefixed(txt, expected_result): + assert _is_prefixed(txt) == expected_result + + +@pytest.mark.parametrize("pic", PATHS_DNG_EXAMPLES) +def test_read_metadata_sidecar_path(make_dng_file, pic): + file_path = make_dng_file(pic) + metadata_writer = MetadataWriter(file_path) + assert isinstance(metadata_writer.sidecar_path, Path) + assert str(metadata_writer.sidecar_path).lower().endswith(".dng.xmp") + + # file does not exits yet, only the name + assert not metadata_writer.sidecar_path.exists() + + +@pytest.mark.parametrize("pic", PATHS_DNG_EXAMPLES) +def test_set_caption(make_dng_file, pic): + metadata_writer = MetadataWriter(make_dng_file(pic)) + assert not metadata_writer.sidecar_path.exists() # sidecar does not exist yet ... + metadata_writer.write_caption("This is a test") + assert metadata_writer.sidecar_path.exists() # ... but now! + result = metadata_writer.get_caption() + assert result + assert result == f"{PREFIX}This is a test" + + +def test_set_caption_on_existing(metadata_writer_on_existing): + assert ( + metadata_writer_on_existing.get_caption() + == "Let's pretend this is the caption of the original image" + ) + + metadata_writer_on_existing.write_caption("Another caption") + assert ( + metadata_writer_on_existing.get_caption() + == f"Let's pretend this is the caption of the original image. {PREFIX}Another caption" + ) + + metadata_writer_on_existing.write_caption("Updated caption") + assert ( + metadata_writer_on_existing.get_caption() + == f"Let's pretend this is the caption of the original image. {PREFIX}Updated caption" + ) + + metadata_writer_on_existing.write_caption("Don't do this!", override=False) + assert ( + metadata_writer_on_existing.get_caption() + == f"Let's pretend this is the caption of the original image. {PREFIX}Updated caption" + ) + + +@pytest.mark.parametrize( + "pic, expected_keywords", + [ + ( + PATHS_DNG_EXAMPLES[0], + [ + "architektur", + "zeit|abend", + f"{PREFIX}hund", + f"{PREFIX}katze", + f"{PREFIX}maus", + ], + ), + ( + PATHS_DNG_EXAMPLES[1], + ["architektur", f"{PREFIX}hund", f"{PREFIX}katze", f"{PREFIX}maus"], + ), + ( + PATHS_DNG_EXAMPLES[2], + [ + "morgen", + "tiere|vögel", + f"{PREFIX}hund", + f"{PREFIX}katze", + f"{PREFIX}maus", + ], + ), + ( + PATHS_DNG_EXAMPLES[3], + [ + "abend", + "architektur", + "bewegungsunschärfe", + "lichter", + "mainz", + "spiegelung", + "wasser", + f"{PREFIX}hund", + f"{PREFIX}katze", + f"{PREFIX}maus", + ], + ), + ( + PATHS_DNG_EXAMPLES[4], + [ + "grau", + "landschaft", + "landschaft|weinberge", + "personen", + "tiefe", + "weg", + f"{PREFIX}hund", + f"{PREFIX}katze", + f"{PREFIX}maus", + ], + ), + ], +) +def test_set_keywords(make_dng_file, pic, expected_keywords): + metadata_writer = MetadataWriter(make_dng_file(pic)) + metadata_writer.write_keywords(["hund", "katze", "maus"]) + result = metadata_writer.get_keywords() + assert result + assert isinstance(result, list) + assert f"{PREFIX}hund" in result + assert f"{PREFIX}katze" in result + assert f"{PREFIX}maus" in result + + +def test_set_keywords_on_existing(): + # TODO + ... diff --git a/test/test_settings.py b/test/test_settings.py index a931d35..ae5736b 100644 --- a/test/test_settings.py +++ b/test/test_settings.py @@ -6,11 +6,9 @@ from photools3.settings import ( PHOTOOLS3_DIR, ROOT_DIR, SRC_DIR, - Settings, + settings, ) -settings = Settings() - def test_photools3_dir(): assert PHOTOOLS3_DIR == Path(__file__).parent.parent.resolve() / "src" / "photools3" diff --git a/uv.lock b/uv.lock index 4f2abe4..dca5d71 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.14" [[package]] @@ -580,6 +580,7 @@ dependencies = [ { name = "pillow" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "pyexiftool" }, { name = "pytest" }, { name = "ruff" }, { name = "typer" }, @@ -593,6 +594,7 @@ requires-dist = [ { name = "pillow", specifier = ">=12.3.0" }, { name = "pydantic", specifier = ">=2.13.5" }, { name = "pydantic-settings", specifier = ">=2.15.0" }, + { name = "pyexiftool", specifier = ">=0.5.6" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "ruff", specifier = ">=0.16.9" }, { name = "typer", specifier = ">=0.12.0" }, @@ -746,6 +748,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, ] +[[package]] +name = "pyexiftool" +version = "0.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/48/406da6691d15abf3c8d399bce8bc588709a5b54e857fd7c22dad2f90c33c/PyExifTool-0.5.6.tar.gz", hash = "sha256:22a972c1c212d1ad5f61916fded5057333dcc48fb8e42eed12d2ff9665b367ae", size = 56365, upload-time = "2023-10-22T23:18:18.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b9/175e9a1f8f3f94b22f622f0fcac853ae2c43cb4ac6034f849269c6086dac/PyExifTool-0.5.6-py3-none-any.whl", hash = "sha256:ac7d7836d2bf373f20aa558528f6b2222c4c0d896ed28c951a3ff8e6cec05a87", size = 51243, upload-time = "2023-10-22T23:18:16.614Z" }, +] + [[package]] name = "pygments" version = "2.21.0"