From 419fb46c3d1cd4824446b24d4ed54b6feaabb2f9 Mon Sep 17 00:00:00 2001 From: Mathias Date: Fri, 2 Oct 2026 12:03:20 +0000 Subject: [PATCH] refactor: simplify CLI command logic by extracting helper functions (#13) Reviewed-on: https://gitea.schneiderserver.site/mathias2784/photools3/pulls/13 Co-authored-by: Mathias --- AGENTS.md | 22 +++++++++ pyproject.toml | 1 + src/photools3/__init__.py | 7 ++- src/photools3/__main__.py | 4 ++ src/photools3/cli.py | 68 +++++++++++++++++++++++++++ src/photools3/llm_session.py | 1 + test/test_cli.py | 90 ++++++++++++++++++++++++++++++++++++ uv.lock | 35 ++++++++++++++ 8 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/photools3/__main__.py create mode 100644 src/photools3/cli.py create mode 100644 test/test_cli.py 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`. 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..4de025a 100644 --- a/src/photools3/__init__.py +++ b/src/photools3/__init__.py @@ -1,2 +1,5 @@ -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..2071050 --- /dev/null +++ b/src/photools3/cli.py @@ -0,0 +1,68 @@ +from pathlib import Path +from typing import Annotated, Any + +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: bool | None = 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: 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) + 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: # 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 ---") + 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/test/test_cli.py b/test/test_cli.py new file mode 100644 index 0000000..af39888 --- /dev/null +++ b/test/test_cli.py @@ -0,0 +1,90 @@ +from pathlib import Path +from unittest.mock import patch + +import pytest +from typer.testing import CliRunner + +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 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"