From b557f6a10e1097bfecbe7af9417bccd7c5c43cee Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Fri, 2 Oct 2026 11:53:11 +0200 Subject: [PATCH 1/5] refactor: simplify CLI command logic by extracting helper functions --- pyproject.toml | 1 + src/photools3/__init__.py | 6 ++-- src/photools3/__main__.py | 4 +++ src/photools3/cli.py | 61 ++++++++++++++++++++++++++++++++++++ src/photools3/llm_session.py | 1 + uv.lock | 35 +++++++++++++++++++++ 6 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 src/photools3/__main__.py create mode 100644 src/photools3/cli.py diff --git a/pyproject.toml b/pyproject.toml index adf2336..e004ca3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "pydantic-settings>=2.15.0", "pytest>=9.1.1", "ruff>=0.16.9", + "typer>=0.12.0", "utils>=0.1.2", ] diff --git a/src/photools3/__init__.py b/src/photools3/__init__.py index d44fcbd..2ca297a 100644 --- a/src/photools3/__init__.py +++ b/src/photools3/__init__.py @@ -1,2 +1,4 @@ -def main() -> None: - print("Hello from photools3!") +from .cli import app + +def main(): + app() diff --git a/src/photools3/__main__.py b/src/photools3/__main__.py new file mode 100644 index 0000000..d5cf0fd --- /dev/null +++ b/src/photools3/__main__.py @@ -0,0 +1,4 @@ +from .cli import app + +if __name__ == "__main__": + app() diff --git a/src/photools3/cli.py b/src/photools3/cli.py new file mode 100644 index 0000000..023c9e3 --- /dev/null +++ b/src/photools3/cli.py @@ -0,0 +1,61 @@ +from pathlib import Path +from typing import Any, Optional + +import typer + +from photools3.llm_session import LlmSessionCaptionsAndKeywords +from photools3.photo import Photo + +app = typer.Typer(help="PhotoTools3: A CLI for image processing.") + +@app.callback(invoke_without_command=True) +def main( + ctx: typer.Context, + version: Optional[bool] = typer.Option(None, "--version", is_eager=True), +): + """ + Main entry point for PhotoTools3 CLI. + """ + if version: + typer.echo("photools3 version 0.1.0") + raise typer.Exit() + + if ctx.invoked_subcommand is None: + typer.echo(ctx.get_help()) + raise typer.Exit() + +@app.command("keywords") +def keywords(file_path: Path = typer.Argument(..., help="Path to the image file")): + """Extract keywords from a single image.""" + if not file_path.exists(): + typer.echo(f"Error: File '{file_path}' does not exist.", err=True) + raise typer.Exit(code=1) + + try: + typer.echo(f"Processing: {file_path}") + result = _run_keywords_extraction(file_path) + _print_results(result) + except Exception as e: + typer.echo(f"Error processing image: {e}", err=True) + raise typer.Exit(code=1) + +def _run_keywords_extraction(file_path: Path) -> Any: + """Handles the core logic of loading the photo and calling the LLM.""" + session = LlmSessionCaptionsAndKeywords() + photo = Photo(file_path) + return session.call([photo]) + +def _print_results(result: Any) -> None: + """Formats and prints the extraction results to the console.""" + typer.echo("\n--- Results ---") + if isinstance(result, dict): + caption = result.get("caption", "No caption found.") + tags = result.get("tags", []) + typer.echo(f"Caption: {caption}") + typer.echo(f"Tags: {', '.join(tags)}") + else: + typer.echo(str(result)) + typer.echo("---------------") + +if __name__ == "__main__": + app() diff --git a/src/photools3/llm_session.py b/src/photools3/llm_session.py index 2cb55e1..492cfee 100644 --- a/src/photools3/llm_session.py +++ b/src/photools3/llm_session.py @@ -129,6 +129,7 @@ class LlmSessionCaptionsAndKeywords(LlmSession): " - Do not add any other text outside of this JSON format!\n" "- When writing captions, ONLY describe what is visible on the photo. Do not interpret!\n" "- Only use keywords from the keyword list, appended below.\n" + "- Answer in German.\n" "\n" "## Keyword list" "\n" diff --git a/uv.lock b/uv.lock index 983647f..4f2abe4 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.14" +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + [[package]] name = "annotated-types" version = "0.8.0" @@ -573,6 +582,7 @@ dependencies = [ { name = "pydantic-settings" }, { name = "pytest" }, { name = "ruff" }, + { name = "typer" }, { name = "utils" }, ] @@ -585,6 +595,7 @@ requires-dist = [ { name = "pydantic-settings", specifier = ">=2.15.0" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "ruff", specifier = ">=0.16.9" }, + { name = "typer", specifier = ">=0.12.0" }, { name = "utils", specifier = ">=0.1.2", index = "https://gitea.schneiderserver.site/api/packages/mathias2784/pypi/simple" }, ] @@ -898,6 +909,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -945,6 +965,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/08/ddcdc06225eaad6de0e48e1002b06d919dbde20582d0662c7af51308e5d6/twine-7.0.0-py3-none-any.whl", hash = "sha256:b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7", size = 43204, upload-time = "2026-07-27T15:58:59.26Z" }, ] +[[package]] +name = "typer" +version = "0.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/f7/57713ba479fd405eb76de31404b2c744c289e336b2d999511ebf51e496f7/typer-0.27.2.tar.gz", hash = "sha256:269b7eb9d3c202ca84b4bc9618cb04ebb43d3d4d1e567e4c768607232c05f945", size = 204045, upload-time = "2026-08-28T10:26:55.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/bf/205d0004930ede8f542fb58f601526fccf4ae7626075ca1e6c4de5d3d652/typer-0.27.2-py3-none-any.whl", hash = "sha256:b3a5fc4342d5fc8fda8fc3010b1cf117e9249aab7fae800c2eff62fd3842d97d", size = 123130, upload-time = "2026-08-28T10:26:53.752Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" -- 2.54.0 From 5c7d300619fcac897161b23a0d91f2b719f3cf83 Mon Sep 17 00:00:00 2001 From: Mathias Date: Fri, 2 Oct 2026 13:43:54 +0200 Subject: [PATCH 2/5] ruff and black --- src/photools3/__init__.py | 1 + src/photools3/cli.py | 15 +++++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/photools3/__init__.py b/src/photools3/__init__.py index 2ca297a..4de025a 100644 --- a/src/photools3/__init__.py +++ b/src/photools3/__init__.py @@ -1,4 +1,5 @@ from .cli import app + def main(): app() diff --git a/src/photools3/cli.py b/src/photools3/cli.py index 023c9e3..2071050 100644 --- a/src/photools3/cli.py +++ b/src/photools3/cli.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Any, Optional +from typing import Annotated, Any import typer @@ -8,10 +8,11 @@ from photools3.photo import Photo app = typer.Typer(help="PhotoTools3: A CLI for image processing.") + @app.callback(invoke_without_command=True) def main( ctx: typer.Context, - version: Optional[bool] = typer.Option(None, "--version", is_eager=True), + version: bool | None = typer.Option(None, "--version", is_eager=True), ): """ Main entry point for PhotoTools3 CLI. @@ -24,8 +25,11 @@ def main( typer.echo(ctx.get_help()) raise typer.Exit() + @app.command("keywords") -def keywords(file_path: Path = typer.Argument(..., help="Path to the image file")): +def keywords( + file_path: Annotated[Path, typer.Argument(..., help="Path to the image file")], +): """Extract keywords from a single image.""" if not file_path.exists(): typer.echo(f"Error: File '{file_path}' does not exist.", err=True) @@ -35,16 +39,18 @@ def keywords(file_path: Path = typer.Argument(..., help="Path to the image file" typer.echo(f"Processing: {file_path}") result = _run_keywords_extraction(file_path) _print_results(result) - except Exception as e: + except Exception as e: # noqa: BLE001 typer.echo(f"Error processing image: {e}", err=True) raise typer.Exit(code=1) + def _run_keywords_extraction(file_path: Path) -> Any: """Handles the core logic of loading the photo and calling the LLM.""" session = LlmSessionCaptionsAndKeywords() photo = Photo(file_path) return session.call([photo]) + def _print_results(result: Any) -> None: """Formats and prints the extraction results to the console.""" typer.echo("\n--- Results ---") @@ -57,5 +63,6 @@ def _print_results(result: Any) -> None: typer.echo(str(result)) typer.echo("---------------") + if __name__ == "__main__": app() -- 2.54.0 From 5e8f1b97742b30d9780bab81361c6e4d99d9d5a4 Mon Sep 17 00:00:00 2001 From: Mathias Date: Fri, 2 Oct 2026 13:54:45 +0200 Subject: [PATCH 3/5] test cli --- test/test_cli.py | 80 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 test/test_cli.py diff --git a/test/test_cli.py b/test/test_cli.py new file mode 100644 index 0000000..f6a98da --- /dev/null +++ b/test/test_cli.py @@ -0,0 +1,80 @@ +import pytest +from typer.testing import CliRunner +from unittest.mock import patch, MagicMock +from pathlib import Path +from photools3.cli import app + +runner = CliRunner() + +@pytest.fixture +def mock_photo_logic(tmp_path): + """Fixture to mock the core logic of keywords command.""" + with patch("photools3.cli.LlmSessionCaptionsAndKeywords") as mocked_session, \ + patch("photools3.cli.Photo") as mocked_photo: + + # Configure session mock + session_instance = mocked_session.return_value + session_instance.call.return_value = { + "caption": "A beautiful test image", + "args": ["test", "mock", "image"] # Wait, the code uses 'tags' + } + # Let me re-check the code in cli.py + # result.get("tags", []) + session_instance.call.return_value = { + "caption": "A beautiful test image", + "tags": ["test", "mock", "image"] + } + + # Configure photo mock + photo_instance = mocked_photo.return_value + photo_instance.path = tmp_path / "test_image.jpg" + + yield { + "session": session_instance, + "photo_class": mocked_photo, + "photo_instance": photo_instance + } + +@pytest.fixture +def dummy_file(tmp_path): + """Fixture to create a dummy file for CLI testing.""" + dummy_path = tmp_path / "test_image.jpg" + dummy_path.write_text("dummy content") + return dummy_path + +def test_keywords_command_success(mock_photo_logic, dummy_file): + """Test the 'keywords' command with a valid file path.""" + # Ensure the mock photo instance points to our dummy file + mock_photo_logic["photo_class"].return_value.path = dummy_file + + result = runner.invoke(app, ["keywords", str(dummy_file)]) + + assert result.exit_code == 0 + assert "Processing: " in result.stdout + assert "Caption: A beautiful test image" in result.stdout + assert "Tags: test, mock, image" in result.stdout + +def test_keywords_command_file_not_found(): + """Test the 'keywords' command with a non-existent file path.""" + non_existent_file = Path("non_existent_file.jpg") + result = runner.invoke(app, ["keywords", str(non_existent_file)]) + + assert result.exit_code == 1 + assert f"Error: File '{non_existent_file}' does not exist." in result.stderr + +def test_keywords_command_error_during_processing(mock_photo_logic, dummy_file): + """Test the 'keywords' command when an exception occurs during processing.""" + # Configure the session mock to raise an exception + mock_photo_logic["session"].call.side_effect = Exception("LLM error") + + result = runner.invoke(app, ["keywords", str(dummy_file)]) + + assert result.exit_code == 1 + assert "Error processing image: LLM error" in result.stderr + +def test_version_command(): + """Test the --version option.""" + result = runner.invoke(app, ["--version"]) + + assert result.exit_code == 0 + assert "photools3 version 0.1.0" in result.stdout -- 2.54.0 From 9047d111232ee2c0755f825b700c13095e634d1a Mon Sep 17 00:00:00 2001 From: Mathias Date: Fri, 2 Oct 2026 14:01:07 +0200 Subject: [PATCH 4/5] AGENTS.md --- AGENTS.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fd16e82 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,22 @@ +# Agent Instructions: PhotoTools3 + +## Environment Setup +Requires a `.env` file with the following variables: +- `llm_api_key`: API key for the LLM provider. +- `llm_api_base_url`: Base URL for the LLM API. +- `llm_default_temperature`: Float value for temperature. + +## Development Toolchain +- **Dependency Management**: Use `uv`. +- **Python Version**: Requires Python `>=3.14`. +- **Testing**: Run `pytest` to execute the test suite in `test/`. +- **Linting & Formatting**: Use `ruff` and `black`. + +## CLI Usage +The primary entry point is the `photools3` command: +- `photools3 keywords `: Extracts keywords and captions from a single image. + +## Architecture & Logic +- **`src/photools3/photo.py`**: Core image processing logic, including resizing, RGB conversion, and generating base64 JPEG encodings specifically for LLM vision compatibility. +- **`src/photools3/llm_session.py`**: Manages the workflow of sending processed images to the LLM and parsing results. +- **Configuration**: The list of keywords to extract is maintained in `config/keywords.txt`. -- 2.54.0 From dff27fb9d6e73248db6a96ee27d723a076d91445 Mon Sep 17 00:00:00 2001 From: Mathias Date: Fri, 2 Oct 2026 14:02:39 +0200 Subject: [PATCH 5/5] ruff and black --- test/test_cli.py | 42 ++++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/test/test_cli.py b/test/test_cli.py index f6a98da..af39888 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -1,40 +1,46 @@ +from pathlib import Path +from unittest.mock import patch + import pytest from typer.testing import CliRunner -from unittest.mock import patch, MagicMock -from pathlib import Path + from photools3.cli import app runner = CliRunner() + @pytest.fixture def mock_photo_logic(tmp_path): """Fixture to mock the core logic of keywords command.""" - with patch("photools3.cli.LlmSessionCaptionsAndKeywords") as mocked_session, \ - patch("photools3.cli.Photo") as mocked_photo: - + with ( + patch("photools3.cli.LlmSessionCaptionsAndKeywords") as mocked_session, + patch("photools3.cli.Photo") as mocked_photo, + ): + # Configure session mock session_instance = mocked_session.return_value session_instance.call.return_value = { "caption": "A beautiful test image", - "args": ["test", "mock", "image"] # Wait, the code uses 'tags' + "args": ["test", "mock", "image"], # Wait, the code uses 'tags' } # Let me re-check the code in cli.py # result.get("tags", []) session_instance.call.return_value = { "caption": "A beautiful test image", - "tags": ["test", "mock", "image"] + "tags": ["test", "mock", "image"], } - + # Configure photo mock photo_instance = mocked_photo.return_value photo_instance.path = tmp_path / "test_image.jpg" - + yield { "session": session_instance, "photo_class": mocked_photo, - "photo_instance": photo_instance + "photo_instance": photo_instance, } + @pytest.fixture def dummy_file(tmp_path): """Fixture to create a dummy file for CLI testing.""" @@ -42,39 +48,43 @@ def dummy_file(tmp_path): dummy_path.write_text("dummy content") return dummy_path + def test_keywords_command_success(mock_photo_logic, dummy_file): """Test the 'keywords' command with a valid file path.""" # Ensure the mock photo instance points to our dummy file mock_photo_logic["photo_class"].return_value.path = dummy_file - + result = runner.invoke(app, ["keywords", str(dummy_file)]) - + assert result.exit_code == 0 assert "Processing: " in result.stdout assert "Caption: A beautiful test image" in result.stdout assert "Tags: test, mock, image" in result.stdout + def test_keywords_command_file_not_found(): """Test the 'keywords' command with a non-existent file path.""" non_existent_file = Path("non_existent_file.jpg") result = runner.invoke(app, ["keywords", str(non_existent_file)]) - + assert result.exit_code == 1 assert f"Error: File '{non_existent_file}' does not exist." in result.stderr + def test_keywords_command_error_during_processing(mock_photo_logic, dummy_file): """Test the 'keywords' command when an exception occurs during processing.""" # Configure the session mock to raise an exception mock_photo_logic["session"].call.side_effect = Exception("LLM error") - + result = runner.invoke(app, ["keywords", str(dummy_file)]) - + assert result.exit_code == 1 assert "Error processing image: LLM error" in result.stderr + def test_version_command(): """Test the --version option.""" result = runner.invoke(app, ["--version"]) - + assert result.exit_code == 0 assert "photools3 version 0.1.0" in result.stdout -- 2.54.0