refactor: simplify CLI command logic by extracting helper functions (#13)
Tests / test_and_lint (push) Successful in 14s
Tests / test_and_lint (push) Successful in 14s
Reviewed-on: #13 Co-authored-by: Mathias <mathias2784@web.de>
This commit was merged in pull request #13.
This commit is contained in:
@@ -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 <image_path>`: 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`.
|
||||||
@@ -15,6 +15,7 @@ dependencies = [
|
|||||||
"pydantic-settings>=2.15.0",
|
"pydantic-settings>=2.15.0",
|
||||||
"pytest>=9.1.1",
|
"pytest>=9.1.1",
|
||||||
"ruff>=0.16.9",
|
"ruff>=0.16.9",
|
||||||
|
"typer>=0.12.0",
|
||||||
"utils>=0.1.2",
|
"utils>=0.1.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
def main() -> None:
|
from .cli import app
|
||||||
print("Hello from photools3!")
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
app()
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .cli import app
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app()
|
||||||
@@ -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()
|
||||||
@@ -129,6 +129,7 @@ class LlmSessionCaptionsAndKeywords(LlmSession):
|
|||||||
" - Do not add any other text outside of this JSON format!\n"
|
" - 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"
|
"- 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"
|
"- Only use keywords from the keyword list, appended below.\n"
|
||||||
|
"- Answer in German.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"## Keyword list"
|
"## Keyword list"
|
||||||
"\n"
|
"\n"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -2,6 +2,15 @@ version = 1
|
|||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.14"
|
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]]
|
[[package]]
|
||||||
name = "annotated-types"
|
name = "annotated-types"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -573,6 +582,7 @@ dependencies = [
|
|||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
|
{ name = "typer" },
|
||||||
{ name = "utils" },
|
{ name = "utils" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -585,6 +595,7 @@ requires-dist = [
|
|||||||
{ name = "pydantic-settings", specifier = ">=2.15.0" },
|
{ name = "pydantic-settings", specifier = ">=2.15.0" },
|
||||||
{ name = "pytest", specifier = ">=9.1.1" },
|
{ name = "pytest", specifier = ">=9.1.1" },
|
||||||
{ name = "ruff", specifier = ">=0.16.9" },
|
{ 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" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "sniffio"
|
name = "sniffio"
|
||||||
version = "1.3.1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.16.0"
|
version = "4.16.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user