From 1366e3b3e76a5c9856a7a6b14a27836de9aa1903 Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Sun, 4 Oct 2026 07:54:33 +0200 Subject: [PATCH 01/15] metadata-writer: first skeleton --- pyproject.toml | 1 + src/photools3/metadata_writer.py | 46 ++++++++++++++++++++++++++++++++ uv.lock | 13 ++++++++- 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 src/photools3/metadata_writer.py 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/metadata_writer.py b/src/photools3/metadata_writer.py new file mode 100644 index 0000000..6f92e91 --- /dev/null +++ b/src/photools3/metadata_writer.py @@ -0,0 +1,46 @@ +import logging +from pathlib import Path + +log = logging.getLogger(__name__) + +# File extensions that should use XMP sidecars +SIDECAR_EXTENSIONS = {".dng", ".nef", ".cr2", ".cr3", ".arw", ".raf", ".orf", ".rw2"} + + +type Caption = str | None +type Keywords = list[str] | None +type Color = str | None + + +class MetadataWriter: + """ + Write metadata (captions and keywords) to image files. + For DNG files, creates/updates XMP sidecar files. + """ + + def __init__( + self, force_sidecar: bool = False, append: bool = True, prefix: str = "photools" + ): + self.force_sidecar = force_sidecar + self.append = append + self.prefix = prefix + + def write_caption(self, path: Path, caption: Caption): ... + + def write_keywords(self, path: Path, keywords: Keywords): ... + + def write_color(self, path: Path, color: Color): ... + + def write( + self, + path: Path, + caption: Caption = None, + keywords: Keywords = None, + color: Color = None, + ): + if caption is not None: + self.write_caption(path, caption) + if keywords is not None: + self.write_keywords(path, keywords) + if color is not None: + self.write_color(path, color) 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" -- 2.54.0 From 084676eca04f4df2beacd2937f1441a68a8edb58 Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 6 Oct 2026 07:52:36 +0200 Subject: [PATCH 02/15] metadata writer: some progress --- .env.template | 2 ++ src/photools3/llm_session.py | 4 +--- src/photools3/metadata_writer.py | 33 ++++++++++++++++++++++++++++++++ src/photools3/settings.py | 5 +++++ test/test_metadata_writer.py | 8 ++++++++ 5 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 test/test_metadata_writer.py diff --git a/.env.template b/.env.template index 69a0aea..a23d47b 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/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 index 6f92e91..ca7714a 100644 --- a/src/photools3/metadata_writer.py +++ b/src/photools3/metadata_writer.py @@ -1,15 +1,48 @@ import logging from pathlib import Path +from typing import Annotated, Any + +import exiftool +from pydantic import StringConstraints + +from photools3.settings import settings log = logging.getLogger(__name__) # File extensions that should use XMP sidecars SIDECAR_EXTENSIONS = {".dng", ".nef", ".cr2", ".cr3", ".arw", ".raf", ".orf", ".rw2"} +XmpKey = Annotated[str, StringConstraints(pattern=r"^\w+\:\w+")] type Caption = str | None type Keywords = list[str] | None type Color = str | None +type XmpMetadata = dict[str, Any] + +XMO_KEYS_CAPTION: list[XmpKey] = [ + "EXIF:ImageDescription", + "XMP:Description", + "IPTC:Caption-Abstract", +] + +XMP_KEYS_TAG: list[XmpKey] = [ + "XMP:HierarchicalSubject", + "XMP:Subject", + "IPTC:Keywords", +] + + +def _prefixed(txt: str) -> str: + return f"{settings.metadata_prefix} {txt}" + + +def read_metadata(path: Path) -> XmpMetadata | None: + try: + with exiftool.ExifToolHelper() as exif: + return exif.get_metadata([str(path)])[0] + except Exception as exc: # noqa: BLE001 + log.error(f"Could not read metadata from {path}: {exc}") + class MetadataWriter: 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..dc0a938 --- /dev/null +++ b/test/test_metadata_writer.py @@ -0,0 +1,8 @@ + +from photools3.metadata_writer import read_metadata +from test.test_photo import PATHS_DNG_EXAMPLES + + +def test_read_metadata(): + result = read_metadata(PATHS_DNG_EXAMPLES[0]) + assert result -- 2.54.0 From 7c77eeb39232179de4ed3f32b22bed835dec4032 Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 6 Oct 2026 08:02:24 +0200 Subject: [PATCH 03/15] install exiftool in pipeline runner --- .gitea/workflows/ci.yml | 9 +++++++++ AGENTS.md | 5 +++++ src/photools3/metadata_writer.py | 1 - test/test_metadata_writer.py | 1 - 4 files changed, 14 insertions(+), 2 deletions(-) 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/src/photools3/metadata_writer.py b/src/photools3/metadata_writer.py index ca7714a..0e2df60 100644 --- a/src/photools3/metadata_writer.py +++ b/src/photools3/metadata_writer.py @@ -44,7 +44,6 @@ def read_metadata(path: Path) -> XmpMetadata | None: log.error(f"Could not read metadata from {path}: {exc}") - class MetadataWriter: """ Write metadata (captions and keywords) to image files. diff --git a/test/test_metadata_writer.py b/test/test_metadata_writer.py index dc0a938..062f25b 100644 --- a/test/test_metadata_writer.py +++ b/test/test_metadata_writer.py @@ -1,4 +1,3 @@ - from photools3.metadata_writer import read_metadata from test.test_photo import PATHS_DNG_EXAMPLES -- 2.54.0 From afc90a9f288afa6068e9ce525754503048a6bdd8 Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 6 Oct 2026 08:10:23 +0200 Subject: [PATCH 04/15] metadata writer: add guard for exiftools installed on system level --- src/photools3/metadata_writer.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/photools3/metadata_writer.py b/src/photools3/metadata_writer.py index 0e2df60..e2d8b8f 100644 --- a/src/photools3/metadata_writer.py +++ b/src/photools3/metadata_writer.py @@ -1,4 +1,5 @@ import logging +import shutil from pathlib import Path from typing import Annotated, Any @@ -9,6 +10,13 @@ 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) + # File extensions that should use XMP sidecars SIDECAR_EXTENSIONS = {".dng", ".nef", ".cr2", ".cr3", ".arw", ".raf", ".orf", ".rw2"} -- 2.54.0 From 81270d9bad59331f2b228c27356602ff9b38ff96 Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 6 Oct 2026 08:12:19 +0200 Subject: [PATCH 05/15] small fix --- test/test_settings.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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" -- 2.54.0 From 4fc2116cfe8ea6477c0c608dfe6d77e0b893aacb Mon Sep 17 00:00:00 2001 From: Mathias Date: Wed, 7 Oct 2026 16:02:01 +0200 Subject: [PATCH 06/15] metadata writer: some progress --- src/photools3/metadata_writer.py | 70 +++++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 19 deletions(-) diff --git a/src/photools3/metadata_writer.py b/src/photools3/metadata_writer.py index e2d8b8f..253193d 100644 --- a/src/photools3/metadata_writer.py +++ b/src/photools3/metadata_writer.py @@ -22,14 +22,14 @@ SIDECAR_EXTENSIONS = {".dng", ".nef", ".cr2", ".cr3", ".arw", ".raf", ".orf", ". XmpKey = Annotated[str, StringConstraints(pattern=r"^\w+\:\w+")] -type Caption = str | None +type Caption = str type Keywords = list[str] | None type Color = str | None type XmpMetadata = dict[str, Any] -XMO_KEYS_CAPTION: list[XmpKey] = [ - "EXIF:ImageDescription", +XMP_KEYS_CAPTION: list[XmpKey] = [ "XMP:Description", + "EXIF:ImageDescription", "IPTC:Caption-Abstract", ] @@ -44,12 +44,8 @@ def _prefixed(txt: str) -> str: return f"{settings.metadata_prefix} {txt}" -def read_metadata(path: Path) -> XmpMetadata | None: - try: - with exiftool.ExifToolHelper() as exif: - return exif.get_metadata([str(path)])[0] - except Exception as exc: # noqa: BLE001 - log.error(f"Could not read metadata from {path}: {exc}") +def _is_prefixed(txt: str) -> bool: + return txt.startswith(settings.metadata_prefix) class MetadataWriter: @@ -59,28 +55,64 @@ class MetadataWriter: """ def __init__( - self, force_sidecar: bool = False, append: bool = True, prefix: str = "photools" + self, + path: Path, + force_sidecar: bool = False, + append: bool = True, ): + self.path = Path self.force_sidecar = force_sidecar self.append = append - self.prefix = prefix - def write_caption(self, path: Path, caption: Caption): ... + self._metadata: XmpMetadata | None = None - def write_keywords(self, path: Path, keywords: Keywords): ... + @property + def sidecar_path(self) -> Path: + ... + return Path() - def write_color(self, path: Path, color: Color): ... + @property + def metadata(self) -> XmpMetadata: + try: + with exiftool.ExifToolHelper() as exif: + metadata = exif.get_metadata([str(self.path)])[0] + except Exception as exc: # noqa: BLE001 + log.error(f"Could not read metadata from {self.path}: {exc}") + assert metadata + return metadata + + def set_metadata(self): + if self.sidecar_path.exists(): + params=["-overwrite_original"] + else: + params=["-o", "%f.xmp"] + # TODO + ... + + def write_caption(self, caption: Caption): + existing = self.metadata.get(XMP_KEYS_CAPTION[0], "") + is_existing_prefixed = _is_prefixed(existing) + if existing and is_existing_prefixed: + return + elif existing and not is_existing_prefixed: + caption = f"{existing}. {_prefixed(caption)}" + metadata_tags = {key: caption for key in XMP_KEYS_CAPTION} + # TODO + ... + + def write_keywords(self, keywords: Keywords): ... + + def write_color(self, color: Color): ... def write( self, - path: Path, - caption: Caption = None, + caption: Caption | None = None, keywords: Keywords = None, color: Color = None, ): if caption is not None: - self.write_caption(path, caption) + self.write_caption(caption) if keywords is not None: - self.write_keywords(path, keywords) + self.write_keywords(keywords) if color is not None: - self.write_color(path, color) + self.write_color(color) -- 2.54.0 From 1c07a78c5957535f248a35baf72cb137194d8e3e Mon Sep 17 00:00:00 2001 From: Mathias Date: Wed, 7 Oct 2026 16:03:15 +0200 Subject: [PATCH 07/15] black --- src/photools3/metadata_writer.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/photools3/metadata_writer.py b/src/photools3/metadata_writer.py index 253193d..c3a49ae 100644 --- a/src/photools3/metadata_writer.py +++ b/src/photools3/metadata_writer.py @@ -68,7 +68,6 @@ class MetadataWriter: @property def sidecar_path(self) -> Path: - ... return Path() @property @@ -83,11 +82,10 @@ class MetadataWriter: def set_metadata(self): if self.sidecar_path.exists(): - params=["-overwrite_original"] + params = ["-overwrite_original"] else: - params=["-o", "%f.xmp"] + params = ["-o", "%f.xmp"] # TODO - ... def write_caption(self, caption: Caption): existing = self.metadata.get(XMP_KEYS_CAPTION[0], "") @@ -98,7 +96,6 @@ class MetadataWriter: caption = f"{existing}. {_prefixed(caption)}" metadata_tags = {key: caption for key in XMP_KEYS_CAPTION} # TODO - ... def write_keywords(self, keywords: Keywords): ... -- 2.54.0 From 72e116a7b854f667ec9fb389927797c48462625d Mon Sep 17 00:00:00 2001 From: Mathias Date: Thu, 8 Oct 2026 12:02:50 +0200 Subject: [PATCH 08/15] metadata writer: some progress --- src/photools3/metadata_writer.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/photools3/metadata_writer.py b/src/photools3/metadata_writer.py index c3a49ae..b5b103e 100644 --- a/src/photools3/metadata_writer.py +++ b/src/photools3/metadata_writer.py @@ -70,32 +70,39 @@ class MetadataWriter: def sidecar_path(self) -> Path: return Path() - @property - def metadata(self) -> XmpMetadata: + def get_metadata(self, *args, **kwargs) -> Any: try: with exiftool.ExifToolHelper() as exif: metadata = exif.get_metadata([str(self.path)])[0] except Exception as exc: # noqa: BLE001 log.error(f"Could not read metadata from {self.path}: {exc}") assert metadata - return metadata + return metadata.get(*args, **kwargs) - def set_metadata(self): + def set_metadata(self, metadata_tags: dict[str, Any]) -> bool: if self.sidecar_path.exists(): params = ["-overwrite_original"] else: params = ["-o", "%f.xmp"] - # TODO + try: + with exiftool.ExifToolHelper() as exif: + exif.set_tags( + [str(self.sidecar_path)], tags=metadata_tags, params=params + ) + except Exception as exc: # noqa + log.error(f"Could not write metadata to file {self.sidecar_path}: {exc}") + return False + return True def write_caption(self, caption: Caption): - existing = self.metadata.get(XMP_KEYS_CAPTION[0], "") + existing = self.get_metadata(XMP_KEYS_CAPTION[0], "") is_existing_prefixed = _is_prefixed(existing) if existing and is_existing_prefixed: return elif existing and not is_existing_prefixed: caption = f"{existing}. {_prefixed(caption)}" metadata_tags = {key: caption for key in XMP_KEYS_CAPTION} - # TODO + self.set_metadata(metadata_tags) def write_keywords(self, keywords: Keywords): ... -- 2.54.0 From b65282972eeeb4e4a0276c25d2c8bfb5d463cd09 Mon Sep 17 00:00:00 2001 From: Mathias Date: Thu, 8 Oct 2026 12:03:36 +0200 Subject: [PATCH 09/15] metadata writer: test setup --- test/test_metadata_writer.py | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/test/test_metadata_writer.py b/test/test_metadata_writer.py index 062f25b..4376840 100644 --- a/test/test_metadata_writer.py +++ b/test/test_metadata_writer.py @@ -1,7 +1,31 @@ -from photools3.metadata_writer import read_metadata +from pathlib import Path + +import pytest + +from photools3.metadata_writer import MetadataWriter from test.test_photo import PATHS_DNG_EXAMPLES -def test_read_metadata(): - result = read_metadata(PATHS_DNG_EXAMPLES[0]) +@pytest.fixture +def make_dng_file(tmp_path): + def _make(index: int = 0) -> Path: + src = PATHS_DNG_EXAMPLES[index] + dst = tmp_path / f"copy_{index}_{src.name}" + return dst + return _make + +@pytest.fixture +def dng_file(make_dng): + return make_dng() + +def test_read_metadata(make_dng_file): + """primitive test for `MetadataWriter`""" + file_path = make_dng_file() + result = MetadataWriter(file_path) assert result + +@pytest.mark.parametrize("idx", range(len(PATHS_DNG_EXAMPLES))) +def test_read_metadata_sidecar_path(make_dng_file, idx): + file_path = make_dng_file(idx) + metadata_writer = MetadataWriter(file_path) + assert isinstance(metadata_writer.sidecar_path, Path) -- 2.54.0 From c36b9d1df4a3f8b785e2f08a0ee6e94e19a6f20d Mon Sep 17 00:00:00 2001 From: Mathias Date: Thu, 8 Oct 2026 12:21:50 +0200 Subject: [PATCH 10/15] fix --- src/photools3/metadata_writer.py | 8 +++---- test/test_metadata_writer.py | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/photools3/metadata_writer.py b/src/photools3/metadata_writer.py index b5b103e..7c9ce32 100644 --- a/src/photools3/metadata_writer.py +++ b/src/photools3/metadata_writer.py @@ -60,15 +60,15 @@ class MetadataWriter: force_sidecar: bool = False, append: bool = True, ): - self.path = Path - self.force_sidecar = force_sidecar - self.append = append + 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() + return Path(str(self.path) + ".xmp") def get_metadata(self, *args, **kwargs) -> Any: try: diff --git a/test/test_metadata_writer.py b/test/test_metadata_writer.py index 4376840..c397fd5 100644 --- a/test/test_metadata_writer.py +++ b/test/test_metadata_writer.py @@ -12,20 +12,56 @@ def make_dng_file(tmp_path): src = PATHS_DNG_EXAMPLES[index] dst = tmp_path / f"copy_{index}_{src.name}" return dst + return _make + @pytest.fixture def dng_file(make_dng): return make_dng() + def test_read_metadata(make_dng_file): """primitive test for `MetadataWriter`""" file_path = make_dng_file() result = MetadataWriter(file_path) assert result + @pytest.mark.parametrize("idx", range(len(PATHS_DNG_EXAMPLES))) def test_read_metadata_sidecar_path(make_dng_file, idx): file_path = make_dng_file(idx) metadata_writer = MetadataWriter(file_path) + print(metadata_writer.sidecar_path) assert isinstance(metadata_writer.sidecar_path, Path) + assert str(metadata_writer.sidecar_path).lower().endswith(".dng.xmp") + + +def test_get_metadata(): + # TODO: Parametrized test on querying metadata fields + ... + + +def test_set_metadata(): + # TODO: Paramtrized test on setting metadata fields + ... + + +def test_set_caption_on_empty(): + # TODO + ... + + +def test_set_caption_on_existing(): + # TODO + ... + + +def test_set_keywords_on_empty(): + # TODO + ... + + +def test_set_keywords_on_existing(): + # TODO + ... -- 2.54.0 From 080b631133388f20a1f7d773e445cfa4afb08384 Mon Sep 17 00:00:00 2001 From: Mathias Date: Thu, 8 Oct 2026 16:00:04 +0200 Subject: [PATCH 11/15] metadata writer: flesh out caption writer --- src/photools3/metadata_writer.py | 29 ++++++++++++++---------- test/test_metadata_writer.py | 39 +++++++++++++------------------- 2 files changed, 33 insertions(+), 35 deletions(-) diff --git a/src/photools3/metadata_writer.py b/src/photools3/metadata_writer.py index 7c9ce32..bb121cd 100644 --- a/src/photools3/metadata_writer.py +++ b/src/photools3/metadata_writer.py @@ -33,7 +33,7 @@ XMP_KEYS_CAPTION: list[XmpKey] = [ "IPTC:Caption-Abstract", ] -XMP_KEYS_TAG: list[XmpKey] = [ +XMP_KEYS_KEYWORDS: list[XmpKey] = [ "XMP:HierarchicalSubject", "XMP:Subject", "IPTC:Keywords", @@ -71,25 +71,26 @@ class MetadataWriter: return Path(str(self.path) + ".xmp") def get_metadata(self, *args, **kwargs) -> Any: + path = self.sidecar_path + if not path.exists(): + return "" try: with exiftool.ExifToolHelper() as exif: - metadata = exif.get_metadata([str(self.path)])[0] + metadata = exif.get_metadata([path])[0] except Exception as exc: # noqa: BLE001 - log.error(f"Could not read metadata from {self.path}: {exc}") - assert metadata + log.error(f"Could not read metadata from {path}: {exc}") + return "" return metadata.get(*args, **kwargs) def set_metadata(self, metadata_tags: dict[str, Any]) -> bool: if self.sidecar_path.exists(): - params = ["-overwrite_original"] + files, params = [str(self.sidecar_path)], ["-overwrite_original"] else: - params = ["-o", "%f.xmp"] + files, params = [str(self.path)], ["-o", str(self.sidecar_path)] try: with exiftool.ExifToolHelper() as exif: - exif.set_tags( - [str(self.sidecar_path)], tags=metadata_tags, params=params - ) - except Exception as exc: # noqa + exif.set_tags(files, tags=metadata_tags, params=params) + except Exception as exc: # noqa: BLE001 log.error(f"Could not write metadata to file {self.sidecar_path}: {exc}") return False return True @@ -104,9 +105,13 @@ class MetadataWriter: metadata_tags = {key: caption for key in XMP_KEYS_CAPTION} self.set_metadata(metadata_tags) - def write_keywords(self, keywords: Keywords): ... + def write_keywords(self, keywords: Keywords): + # TODO + ... - def write_color(self, color: Color): ... + def write_color(self, color: Color): + # TODO + ... def write( self, diff --git a/test/test_metadata_writer.py b/test/test_metadata_writer.py index c397fd5..006b842 100644 --- a/test/test_metadata_writer.py +++ b/test/test_metadata_writer.py @@ -1,8 +1,12 @@ +import shutil from pathlib import Path import pytest -from photools3.metadata_writer import MetadataWriter +from photools3.metadata_writer import ( + XMP_KEYS_CAPTION, + MetadataWriter, +) from test.test_photo import PATHS_DNG_EXAMPLES @@ -11,16 +15,12 @@ def make_dng_file(tmp_path): def _make(index: int = 0) -> Path: src = PATHS_DNG_EXAMPLES[index] dst = tmp_path / f"copy_{index}_{src.name}" + shutil.copy(src, dst) return dst return _make -@pytest.fixture -def dng_file(make_dng): - return make_dng() - - def test_read_metadata(make_dng_file): """primitive test for `MetadataWriter`""" file_path = make_dng_file() @@ -28,28 +28,21 @@ def test_read_metadata(make_dng_file): assert result -@pytest.mark.parametrize("idx", range(len(PATHS_DNG_EXAMPLES))) -def test_read_metadata_sidecar_path(make_dng_file, idx): - file_path = make_dng_file(idx) +@pytest.mark.parametrize("pic_no", range(len(PATHS_DNG_EXAMPLES))) +def test_read_metadata_sidecar_path(make_dng_file, pic_no): + file_path = make_dng_file(pic_no) metadata_writer = MetadataWriter(file_path) print(metadata_writer.sidecar_path) assert isinstance(metadata_writer.sidecar_path, Path) assert str(metadata_writer.sidecar_path).lower().endswith(".dng.xmp") -def test_get_metadata(): - # TODO: Parametrized test on querying metadata fields - ... - - -def test_set_metadata(): - # TODO: Paramtrized test on setting metadata fields - ... - - -def test_set_caption_on_empty(): - # TODO - ... +@pytest.mark.parametrize("pic_no", range(len(PATHS_DNG_EXAMPLES))) +def test_set_caption(make_dng_file, pic_no): + metadata_writer = MetadataWriter(make_dng_file(pic_no)) + metadata_writer.write_caption("This is a test") + result = metadata_writer.get_metadata(XMP_KEYS_CAPTION[0]) + assert result def test_set_caption_on_existing(): @@ -57,7 +50,7 @@ def test_set_caption_on_existing(): ... -def test_set_keywords_on_empty(): +def test_set_keywords(): # TODO ... -- 2.54.0 From 05e202cd311e03c8e6dc3d760f63f83ef58d1937 Mon Sep 17 00:00:00 2001 From: Mathias Date: Fri, 9 Oct 2026 22:16:02 +0100 Subject: [PATCH 12/15] metadata writer: some improvements, especially tests --- src/photools3/metadata_writer.py | 64 +++++++++++++++++++++----------- test/test_metadata_writer.py | 56 ++++++++++++++++++++-------- 2 files changed, 83 insertions(+), 37 deletions(-) diff --git a/src/photools3/metadata_writer.py b/src/photools3/metadata_writer.py index bb121cd..06d5fd2 100644 --- a/src/photools3/metadata_writer.py +++ b/src/photools3/metadata_writer.py @@ -23,7 +23,7 @@ SIDECAR_EXTENSIONS = {".dng", ".nef", ".cr2", ".cr3", ".arw", ".raf", ".orf", ". XmpKey = Annotated[str, StringConstraints(pattern=r"^\w+\:\w+")] type Caption = str -type Keywords = list[str] | None +type Keywords = list[str] type Color = str | None type XmpMetadata = dict[str, Any] @@ -70,44 +70,66 @@ class MetadataWriter: def sidecar_path(self) -> Path: return Path(str(self.path) + ".xmp") - def get_metadata(self, *args, **kwargs) -> Any: - path = self.sidecar_path + @property + def metadata(self) -> Any: + path = self.sidecar_path if self.sidecar_path.exists() else self.path if not path.exists(): - return "" + 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 "" - return metadata.get(*args, **kwargs) + return None + return metadata - def set_metadata(self, metadata_tags: dict[str, Any]) -> bool: - if self.sidecar_path.exists(): - files, params = [str(self.sidecar_path)], ["-overwrite_original"] - else: - files, params = [str(self.path)], ["-o", str(self.sidecar_path)] + 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: - exif.set_tags(files, tags=metadata_tags, params=params) + 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}") - return False - return True - def write_caption(self, caption: Caption): - existing = self.get_metadata(XMP_KEYS_CAPTION[0], "") + def write_caption( + self, caption: Caption, include_prefix: bool = True, override: bool = True + ): + if include_prefix: + caption = _prefixed(caption) + existing = self.get_caption() is_existing_prefixed = _is_prefixed(existing) - if existing and is_existing_prefixed: + if existing and is_existing_prefixed and not override: return elif existing and not is_existing_prefixed: caption = f"{existing}. {_prefixed(caption)}" metadata_tags = {key: caption for key in XMP_KEYS_CAPTION} self.set_metadata(metadata_tags) - def write_keywords(self, keywords: Keywords): - # TODO - ... + 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): # TODO @@ -116,7 +138,7 @@ class MetadataWriter: def write( self, caption: Caption | None = None, - keywords: Keywords = None, + keywords: Keywords | None = None, color: Color = None, ): if caption is not None: diff --git a/test/test_metadata_writer.py b/test/test_metadata_writer.py index 006b842..4c7405b 100644 --- a/test/test_metadata_writer.py +++ b/test/test_metadata_writer.py @@ -4,44 +4,61 @@ from pathlib import Path import pytest from photools3.metadata_writer import ( - XMP_KEYS_CAPTION, MetadataWriter, + _is_prefixed, + _prefixed, ) from test.test_photo import PATHS_DNG_EXAMPLES @pytest.fixture def make_dng_file(tmp_path): - def _make(index: int = 0) -> Path: - src = PATHS_DNG_EXAMPLES[index] - dst = tmp_path / f"copy_{index}_{src.name}" + def _make(src: Path) -> Path: + dst = tmp_path / f"copy_{src.name}" shutil.copy(src, dst) return dst return _make -def test_read_metadata(make_dng_file): +@pytest.mark.parametrize("pic", PATHS_DNG_EXAMPLES) +def test_read_metadata(make_dng_file, pic): """primitive test for `MetadataWriter`""" - file_path = make_dng_file() + file_path = make_dng_file(pic) result = MetadataWriter(file_path) assert result -@pytest.mark.parametrize("pic_no", range(len(PATHS_DNG_EXAMPLES))) -def test_read_metadata_sidecar_path(make_dng_file, pic_no): - file_path = make_dng_file(pic_no) +def test_prefixed(): + txt = "bla_bla" + txt_prefixed = _prefixed(txt) + assert txt_prefixed == "photools: bla_bla" + + +def test_is_prefixed(): + txt_prefixed = "photools: some text" + txt_not_prefixed = "prefix is missing here" + txt_false_prefix = "some_perfix: but not the right one" + + assert _is_prefixed(txt_prefixed) + assert not _is_prefixed(txt_not_prefixed) + assert not _is_prefixed(txt_false_prefix) + + +@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) print(metadata_writer.sidecar_path) assert isinstance(metadata_writer.sidecar_path, Path) assert str(metadata_writer.sidecar_path).lower().endswith(".dng.xmp") -@pytest.mark.parametrize("pic_no", range(len(PATHS_DNG_EXAMPLES))) -def test_set_caption(make_dng_file, pic_no): - metadata_writer = MetadataWriter(make_dng_file(pic_no)) +@pytest.mark.parametrize("pic", PATHS_DNG_EXAMPLES) +def test_set_caption(make_dng_file, pic): + metadata_writer = MetadataWriter(make_dng_file(pic)) metadata_writer.write_caption("This is a test") - result = metadata_writer.get_metadata(XMP_KEYS_CAPTION[0]) + result = metadata_writer.get_caption() assert result @@ -50,9 +67,16 @@ def test_set_caption_on_existing(): ... -def test_set_keywords(): - # TODO - ... +@pytest.mark.parametrize("pic", PATHS_DNG_EXAMPLES) +def test_set_keywords(make_dng_file, pic): + 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 "photools: hund" in result + assert "photools: katze" in result + assert "photools: maus" in result def test_set_keywords_on_existing(): -- 2.54.0 From 05021baac93109b45d4a1b69135baa93a60f7fdf Mon Sep 17 00:00:00 2001 From: Mathias Date: Fri, 9 Oct 2026 23:27:28 +0100 Subject: [PATCH 13/15] captions improvements --- src/photools3/metadata_writer.py | 28 +++++++++++++++------ test/test_metadata_writer.py | 43 +++++++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/photools3/metadata_writer.py b/src/photools3/metadata_writer.py index 06d5fd2..9f8c318 100644 --- a/src/photools3/metadata_writer.py +++ b/src/photools3/metadata_writer.py @@ -48,6 +48,11 @@ 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. @@ -112,14 +117,23 @@ class MetadataWriter: def write_caption( self, caption: Caption, include_prefix: bool = True, override: bool = True ): - if include_prefix: - caption = _prefixed(caption) - existing = self.get_caption() - is_existing_prefixed = _is_prefixed(existing) - if existing and is_existing_prefixed and not override: + 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 - elif existing and not is_existing_prefixed: - caption = f"{existing}. {_prefixed(caption)}" + if not include_prefix and existing_no_prefix and not override: + return + if include_prefix: + caption = settings.metadata_prefix.join([existing_no_prefix, f" {caption}"]) metadata_tags = {key: caption for key in XMP_KEYS_CAPTION} self.set_metadata(metadata_tags) diff --git a/test/test_metadata_writer.py b/test/test_metadata_writer.py index 4c7405b..7597d59 100644 --- a/test/test_metadata_writer.py +++ b/test/test_metadata_writer.py @@ -21,6 +21,16 @@ def make_dng_file(tmp_path): 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`""" @@ -49,22 +59,47 @@ def test_is_prefixed(): def test_read_metadata_sidecar_path(make_dng_file, pic): file_path = make_dng_file(pic) metadata_writer = MetadataWriter(file_path) - print(metadata_writer.sidecar_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 == "photools: This is a test" -def test_set_caption_on_existing(): - # TODO - ... +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() + == "Let's pretend this is the caption of the original image. photools: Another caption" + ) + + metadata_writer_on_existing.write_caption("Updated caption") + assert ( + metadata_writer_on_existing.get_caption() + == "Let's pretend this is the caption of the original image. photools: Updated caption" + ) + + metadata_writer_on_existing.write_caption("Don't do this!", override=False) + assert ( + metadata_writer_on_existing.get_caption() + == "Let's pretend this is the caption of the original image. photools: Updated caption" + ) @pytest.mark.parametrize("pic", PATHS_DNG_EXAMPLES) -- 2.54.0 From 63d628c35491e9b6dbec8255dfe927ec6aafeb78 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 10 Oct 2026 12:09:37 +0100 Subject: [PATCH 14/15] tests improvements --- src/photools3/metadata_writer.py | 14 ++-- test/test_metadata_writer.py | 127 ++++++++++++++++++++++++++----- 2 files changed, 114 insertions(+), 27 deletions(-) diff --git a/src/photools3/metadata_writer.py b/src/photools3/metadata_writer.py index 9f8c318..7370838 100644 --- a/src/photools3/metadata_writer.py +++ b/src/photools3/metadata_writer.py @@ -17,14 +17,11 @@ _EXIFTOOL_MISSING_MSG = ( if shutil.which("exiftool") is None: raise FileNotFoundError(_EXIFTOOL_MISSING_MSG) -# File extensions that should use XMP sidecars -SIDECAR_EXTENSIONS = {".dng", ".nef", ".cr2", ".cr3", ".arw", ".raf", ".orf", ".rw2"} - XmpKey = Annotated[str, StringConstraints(pattern=r"^\w+\:\w+")] type Caption = str type Keywords = list[str] -type Color = str | None +type Color = int type XmpMetadata = dict[str, Any] XMP_KEYS_CAPTION: list[XmpKey] = [ @@ -41,7 +38,7 @@ XMP_KEYS_KEYWORDS: list[XmpKey] = [ def _prefixed(txt: str) -> str: - return f"{settings.metadata_prefix} {txt}" + return f"{settings.metadata_prefix}{txt}" def _is_prefixed(txt: str) -> bool: @@ -133,7 +130,7 @@ class MetadataWriter: if not include_prefix and existing_no_prefix and not override: return if include_prefix: - caption = settings.metadata_prefix.join([existing_no_prefix, f" {caption}"]) + caption = f"{existing_no_prefix}{_prefixed(caption)}" metadata_tags = {key: caption for key in XMP_KEYS_CAPTION} self.set_metadata(metadata_tags) @@ -146,14 +143,13 @@ class MetadataWriter: self.set_metadata(metadata_tags) def write_color(self, color: Color): - # TODO - ... + self.set_metadata({"XMP-darktable:Colorlabels": color}) def write( self, caption: Caption | None = None, keywords: Keywords | None = None, - color: Color = None, + color: Color | None = None, ): if caption is not None: self.write_caption(caption) diff --git a/test/test_metadata_writer.py b/test/test_metadata_writer.py index 7597d59..48f163e 100644 --- a/test/test_metadata_writer.py +++ b/test/test_metadata_writer.py @@ -37,22 +37,56 @@ def test_read_metadata(make_dng_file, pic): 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 == "photools: bla_bla" + assert txt_prefixed == "photools|bla_bla" -def test_is_prefixed(): - txt_prefixed = "photools: some text" - txt_not_prefixed = "prefix is missing here" - txt_false_prefix = "some_perfix: but not the right one" - - assert _is_prefixed(txt_prefixed) - assert not _is_prefixed(txt_not_prefixed) - assert not _is_prefixed(txt_false_prefix) +@pytest.mark.parametrize( + "txt, expected_result", + [ + ("photools|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) @@ -74,7 +108,7 @@ def test_set_caption(make_dng_file, pic): assert metadata_writer.sidecar_path.exists() # ... but now! result = metadata_writer.get_caption() assert result - assert result == "photools: This is a test" + assert result == "photools|This is a test" def test_set_caption_on_existing(metadata_writer_on_existing): @@ -86,32 +120,89 @@ def test_set_caption_on_existing(metadata_writer_on_existing): metadata_writer_on_existing.write_caption("Another caption") assert ( metadata_writer_on_existing.get_caption() - == "Let's pretend this is the caption of the original image. photools: Another caption" + == "Let's pretend this is the caption of the original image. photools|Another caption" ) metadata_writer_on_existing.write_caption("Updated caption") assert ( metadata_writer_on_existing.get_caption() - == "Let's pretend this is the caption of the original image. photools: Updated caption" + == "Let's pretend this is the caption of the original image. photools|Updated caption" ) metadata_writer_on_existing.write_caption("Don't do this!", override=False) assert ( metadata_writer_on_existing.get_caption() - == "Let's pretend this is the caption of the original image. photools: Updated caption" + == "Let's pretend this is the caption of the original image. photools|Updated caption" ) -@pytest.mark.parametrize("pic", PATHS_DNG_EXAMPLES) -def test_set_keywords(make_dng_file, pic): +@pytest.mark.parametrize( + "pic, expected_keywords", + [ + ( + PATHS_DNG_EXAMPLES[0], + [ + "architektur", + "zeit|abend", + "photools|hund", + "photools|katze", + "photools|maus", + ], + ), + ( + PATHS_DNG_EXAMPLES[1], + ["architektur", "photools|hund", "photools|katze", "photools|maus"], + ), + ( + PATHS_DNG_EXAMPLES[2], + [ + "morgen", + "tiere|vögel", + "photools|hund", + "photools|katze", + "photools|maus", + ], + ), + ( + PATHS_DNG_EXAMPLES[3], + [ + "abend", + "architektur", + "bewegungsunschärfe", + "lichter", + "mainz", + "spiegelung", + "wasser", + "photools|hund", + "photools|katze", + "photools|maus", + ], + ), + ( + PATHS_DNG_EXAMPLES[4], + [ + "grau", + "landschaft", + "landschaft|weinberge", + "personen", + "tiefe", + "weg", + "photools|hund", + "photools|katze", + "photools|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 "photools: hund" in result - assert "photools: katze" in result - assert "photools: maus" in result + assert "photools|hund" in result + assert "photools|katze" in result + assert "photools|maus" in result def test_set_keywords_on_existing(): -- 2.54.0 From f9ed6c6bed16df89f76304f731ad0ff8dc3024c6 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 10 Oct 2026 13:20:53 +0100 Subject: [PATCH 15/15] make prefix testing more robust --- .env.template | 2 +- test/test_metadata_writer.py | 47 +++++++++++++++++++----------------- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/.env.template b/.env.template index a23d47b..39cc3c7 100644 --- a/.env.template +++ b/.env.template @@ -2,4 +2,4 @@ LLM_API_BASE_URL = "http://localhost:1234/v1" LLM_API_KEY = "not-relevant" LLM_DEFAULT_TEMPERATURE = 1.0 -METADATA_PREFIX = "photools:" +METADATA_PREFIX = "photools|" diff --git a/test/test_metadata_writer.py b/test/test_metadata_writer.py index 48f163e..b2b96a6 100644 --- a/test/test_metadata_writer.py +++ b/test/test_metadata_writer.py @@ -8,8 +8,11 @@ from photools3.metadata_writer import ( _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): @@ -74,13 +77,13 @@ def test_get_keywords(make_dng_file, pic, expected_keywords): def test_prefixed(): txt = "bla_bla" txt_prefixed = _prefixed(txt) - assert txt_prefixed == "photools|bla_bla" + assert txt_prefixed == f"{PREFIX}bla_bla" @pytest.mark.parametrize( "txt, expected_result", [ - ("photools|some text", True), + (f"{PREFIX}some text", True), ("prefix is missing here", False), ("some_perfix|but not the right one", False), ], @@ -108,7 +111,7 @@ def test_set_caption(make_dng_file, pic): assert metadata_writer.sidecar_path.exists() # ... but now! result = metadata_writer.get_caption() assert result - assert result == "photools|This is a test" + assert result == f"{PREFIX}This is a test" def test_set_caption_on_existing(metadata_writer_on_existing): @@ -120,19 +123,19 @@ def test_set_caption_on_existing(metadata_writer_on_existing): metadata_writer_on_existing.write_caption("Another caption") assert ( metadata_writer_on_existing.get_caption() - == "Let's pretend this is the caption of the original image. photools|Another 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() - == "Let's pretend this is the caption of the original image. photools|Updated 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() - == "Let's pretend this is the caption of the original image. photools|Updated caption" + == f"Let's pretend this is the caption of the original image. {PREFIX}Updated caption" ) @@ -144,23 +147,23 @@ def test_set_caption_on_existing(metadata_writer_on_existing): [ "architektur", "zeit|abend", - "photools|hund", - "photools|katze", - "photools|maus", + f"{PREFIX}hund", + f"{PREFIX}katze", + f"{PREFIX}maus", ], ), ( PATHS_DNG_EXAMPLES[1], - ["architektur", "photools|hund", "photools|katze", "photools|maus"], + ["architektur", f"{PREFIX}hund", f"{PREFIX}katze", f"{PREFIX}maus"], ), ( PATHS_DNG_EXAMPLES[2], [ "morgen", "tiere|vögel", - "photools|hund", - "photools|katze", - "photools|maus", + f"{PREFIX}hund", + f"{PREFIX}katze", + f"{PREFIX}maus", ], ), ( @@ -173,9 +176,9 @@ def test_set_caption_on_existing(metadata_writer_on_existing): "mainz", "spiegelung", "wasser", - "photools|hund", - "photools|katze", - "photools|maus", + f"{PREFIX}hund", + f"{PREFIX}katze", + f"{PREFIX}maus", ], ), ( @@ -187,9 +190,9 @@ def test_set_caption_on_existing(metadata_writer_on_existing): "personen", "tiefe", "weg", - "photools|hund", - "photools|katze", - "photools|maus", + f"{PREFIX}hund", + f"{PREFIX}katze", + f"{PREFIX}maus", ], ), ], @@ -200,9 +203,9 @@ def test_set_keywords(make_dng_file, pic, expected_keywords): result = metadata_writer.get_keywords() assert result assert isinstance(result, list) - assert "photools|hund" in result - assert "photools|katze" in result - assert "photools|maus" in result + assert f"{PREFIX}hund" in result + assert f"{PREFIX}katze" in result + assert f"{PREFIX}maus" in result def test_set_keywords_on_existing(): -- 2.54.0