E2e workflow #21
@@ -0,0 +1,86 @@
|
|||||||
|
# PhotoTools3
|
||||||
|
|
||||||
|
PhotoTools3 uses an OpenAI-compatible vision API to generate photo captions and
|
||||||
|
keywords, then writes the results to photo metadata. It can also assess photo
|
||||||
|
quality.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Install the Python dependencies with `uv` and make sure ExifTool is installed and
|
||||||
|
available on `PATH` (it is a system dependency, not a Python package):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a `.env` file in the project root with the LLM connection details and the
|
||||||
|
prefix used to identify metadata written by PhotoTools3:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
llm_api_base_url=https://your-openai-compatible-api/v1
|
||||||
|
llm_api_key=your-api-key
|
||||||
|
llm_default_temperature=0.2
|
||||||
|
metadata_prefix=photools3:
|
||||||
|
```
|
||||||
|
|
||||||
|
Keyword suggestions are restricted to the vocabulary in `config/keywords.txt`.
|
||||||
|
|
||||||
|
## Generate metadata
|
||||||
|
|
||||||
|
The `metadata-magic` command accepts one or more image paths and/or directories.
|
||||||
|
The default mode is `both`, which generates and writes a caption and keywords:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run photools3 metadata-magic photo.dng
|
||||||
|
```
|
||||||
|
|
||||||
|
Choose a mode with `--mode`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate only keywords
|
||||||
|
uv run photools3 metadata-magic photo.dng --mode keywords
|
||||||
|
|
||||||
|
# Generate only a caption
|
||||||
|
uv run photools3 metadata-magic photo.dng --mode captions
|
||||||
|
|
||||||
|
# Explicitly request both (the default)
|
||||||
|
uv run photools3 metadata-magic photo.dng --mode both
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass multiple files to process them sequentially as a batch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run photools3 metadata-magic photo1.dng photo2.jpg --mode both
|
||||||
|
```
|
||||||
|
|
||||||
|
Directories are scanned at the top level by default. Add `--recursive` to
|
||||||
|
include supported images in subdirectories. You can combine directories and
|
||||||
|
individual file paths:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run photools3 metadata-magic ./photos --recursive --mode keywords
|
||||||
|
uv run photools3 metadata-magic ./photos photo-to-include.dng --mode captions
|
||||||
|
```
|
||||||
|
|
||||||
|
The command reports each processed file and exits with a non-zero status if any
|
||||||
|
file fails. In a batch, it continues processing the remaining files after an
|
||||||
|
individual failure. Generated metadata is written using ExifTool and XMP
|
||||||
|
sidecars.
|
||||||
|
|
||||||
|
## Assess photo quality
|
||||||
|
|
||||||
|
The `quality` command reports an assessment without changing photo metadata:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run photools3 quality photo.dng
|
||||||
|
```
|
||||||
|
|
||||||
|
## Help
|
||||||
|
|
||||||
|
Show the available commands and options with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run photools3 --help
|
||||||
|
uv run photools3 metadata-magic --help
|
||||||
|
uv run photools3 quality --help
|
||||||
|
```
|
||||||
|
|||||||
+109
-26
@@ -3,13 +3,16 @@ from pathlib import Path
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
from PIL import Image
|
||||||
from utils.logging_config import LoggingSettings
|
from utils.logging_config import LoggingSettings
|
||||||
|
|
||||||
from photools3.llm_session import (
|
from photools3.llm_session import LlmSessionQuality
|
||||||
LlmSessionCaptionsAndKeywords,
|
|
||||||
LlmSessionQuality,
|
|
||||||
)
|
|
||||||
from photools3.photo import Photo
|
from photools3.photo import Photo
|
||||||
|
from photools3.processing import (
|
||||||
|
ProcessingMode,
|
||||||
|
process_batch,
|
||||||
|
process_photo,
|
||||||
|
)
|
||||||
|
|
||||||
app = typer.Typer(help="PhotoTools3: A CLI for image processing.")
|
app = typer.Typer(help="PhotoTools3: A CLI for image processing.")
|
||||||
LoggingSettings()
|
LoggingSettings()
|
||||||
@@ -21,9 +24,7 @@ def main(
|
|||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
version: bool | None = typer.Option(None, "--version", is_eager=True),
|
version: bool | None = typer.Option(None, "--version", is_eager=True),
|
||||||
):
|
):
|
||||||
"""
|
"""Main entry point for PhotoTools3 CLI."""
|
||||||
Main entry point for PhotoTools3 CLI.
|
|
||||||
"""
|
|
||||||
if version:
|
if version:
|
||||||
typer.echo("photools3 version 0.1.0")
|
typer.echo("photools3 version 0.1.0")
|
||||||
raise typer.Exit()
|
raise typer.Exit()
|
||||||
@@ -33,36 +34,118 @@ def main(
|
|||||||
raise typer.Exit()
|
raise typer.Exit()
|
||||||
|
|
||||||
|
|
||||||
@app.command("keywords")
|
@app.command("metadata-magic")
|
||||||
def keywords(
|
def metadata_magic(
|
||||||
file_path: Annotated[Path, typer.Argument(..., help="Path to the image file")],
|
paths: Annotated[
|
||||||
|
list[Path], typer.Argument(..., help="Photo file(s) and/or directory paths")
|
||||||
|
],
|
||||||
|
mode: Annotated[
|
||||||
|
ProcessingMode,
|
||||||
|
typer.Option(
|
||||||
|
"--mode",
|
||||||
|
case_sensitive=False,
|
||||||
|
help="Metadata to generate and write",
|
||||||
|
),
|
||||||
|
] = ProcessingMode.BOTH,
|
||||||
|
recursive: Annotated[
|
||||||
|
bool,
|
||||||
|
typer.Option(
|
||||||
|
"--recursive/--no-recursive",
|
||||||
|
help="Recursively discover photos inside input directories",
|
||||||
|
),
|
||||||
|
] = False,
|
||||||
):
|
):
|
||||||
session = LlmSessionCaptionsAndKeywords()
|
"""Generate and write keywords, captions, or both for photos."""
|
||||||
_run_llm_task(file_path, session)
|
_run_processing(paths, mode, recursive)
|
||||||
|
|
||||||
|
|
||||||
@app.command("quality")
|
@app.command("quality")
|
||||||
def quality(
|
def quality(
|
||||||
file_path: Annotated[Path, typer.Argument(..., help="Path to the image file")],
|
file_path: Annotated[Path, typer.Argument(..., help="Path to the image file")],
|
||||||
):
|
):
|
||||||
session = LlmSessionQuality()
|
"""Assess a photo's quality without changing its metadata."""
|
||||||
_run_llm_task(file_path, session)
|
|
||||||
|
|
||||||
|
|
||||||
def _run_llm_task(
|
|
||||||
file_path: Path, session: LlmSessionCaptionsAndKeywords | LlmSessionQuality
|
|
||||||
):
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Processing: {file_path}")
|
with LlmSessionQuality() as session, Photo(file_path) as photo:
|
||||||
photo = Photo(file_path)
|
result = session.call([photo])
|
||||||
result = session.call([photo])
|
logger.info("Quality assessment for %s: %s", file_path, result)
|
||||||
logger.info(result)
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.error(f"Error: File '{file_path}' does not exist.")
|
logger.error("Photo not found: %s", file_path)
|
||||||
|
raise typer.Exit(code=1) from None
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error assessing photo %s: %s", file_path, exc)
|
||||||
|
raise typer.Exit(code=1) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _run_processing(inputs: list[Path], mode: ProcessingMode, recursive: bool) -> None:
|
||||||
|
photo_paths = _expand_photo_paths(inputs, recursive)
|
||||||
|
if not photo_paths:
|
||||||
|
logger.error("No supported photo files found in the given input paths.")
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
|
logger.info("Processing %d photo(s) in %s mode", len(photo_paths), mode.value)
|
||||||
|
if len(photo_paths) == 1:
|
||||||
|
path = photo_paths[0]
|
||||||
|
try:
|
||||||
|
process_photo(path, mode)
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error("Photo not found: %s", path)
|
||||||
|
raise typer.Exit(code=1) from None
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to process %s: %s", path, exc)
|
||||||
|
raise typer.Exit(code=1) from exc
|
||||||
|
logger.info("Processed: %s", path)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
outcomes = process_batch(photo_paths, mode)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Batch processing failed: %s", exc)
|
||||||
|
raise typer.Exit(code=1) from exc
|
||||||
|
|
||||||
|
failures = 0
|
||||||
|
for outcome in outcomes:
|
||||||
|
if outcome.succeeded:
|
||||||
|
logger.info("Processed: %s", outcome.path)
|
||||||
|
else:
|
||||||
|
failures += 1
|
||||||
|
logger.error("Failed to process %s: %s", outcome.path, outcome.error)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Batch complete: %d succeeded, %d failed", len(outcomes) - failures, failures
|
||||||
|
)
|
||||||
|
if failures:
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
logger.error(f"Error processing image: {e}")
|
|
||||||
|
|
||||||
|
def _expand_photo_paths(inputs: list[Path], recursive: bool) -> list[Path]:
|
||||||
|
"""Expand directories to supported image files, preserving input order."""
|
||||||
|
supported_extensions = {
|
||||||
|
".dng",
|
||||||
|
*(extension.lower() for extension in Image.registered_extensions()),
|
||||||
|
}
|
||||||
|
discovered: list[Path] = []
|
||||||
|
|
||||||
|
for path in inputs:
|
||||||
|
if not path.is_dir():
|
||||||
|
discovered.append(path)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
candidates = path.rglob("*") if recursive else path.iterdir()
|
||||||
|
discovered.extend(
|
||||||
|
sorted(
|
||||||
|
candidate
|
||||||
|
for candidate in candidates
|
||||||
|
if candidate.is_file()
|
||||||
|
and candidate.suffix.lower() in supported_extensions
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error("Could not read directory %s: %s", path, exc)
|
||||||
|
raise typer.Exit(code=1) from exc
|
||||||
|
|
||||||
|
# Avoid processing the same exact path more than once while retaining order.
|
||||||
|
return list(dict.fromkeys(discovered))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+165
-68
@@ -1,100 +1,197 @@
|
|||||||
import logging
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
from photools3.cli import app
|
from photools3.cli import app
|
||||||
|
from photools3.processing import ProcessingMode
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|
||||||
CLI_LOGGER = "photools3.cli"
|
|
||||||
|
def _success_outcome(path: Path):
|
||||||
|
return SimpleNamespace(path=path, succeeded=True, error=None)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
def test_metadata_magic_command_defaults_to_both_for_single_photo(tmp_path):
|
||||||
def mock_photo_logic(tmp_path):
|
image_path = tmp_path / "photo.jpg"
|
||||||
"""Fixture to mock the core logic of keywords command."""
|
image_path.write_bytes(b"test image")
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("photools3.cli.LlmSessionCaptionsAndKeywords") as mocked_session,
|
patch("photools3.cli.process_photo") as process_photo,
|
||||||
patch("photools3.cli.Photo") as mocked_photo,
|
patch("photools3.cli.process_batch") as process_batch,
|
||||||
):
|
):
|
||||||
|
result = runner.invoke(app, ["metadata-magic", str(image_path)])
|
||||||
# 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, caplog):
|
|
||||||
"""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
|
|
||||||
|
|
||||||
with caplog.at_level(logging.INFO, logger=CLI_LOGGER):
|
|
||||||
result = runner.invoke(app, ["keywords", str(dummy_file)])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Processing: " in caplog.text
|
process_photo.assert_called_once_with(image_path, ProcessingMode.BOTH)
|
||||||
# The result is logged as a raw dictionary string
|
process_batch.assert_not_called()
|
||||||
assert (
|
|
||||||
"{'caption': 'A beautiful test image', 'tags': ['test', 'mock', 'image']}"
|
|
||||||
in caplog.text
|
def test_metadata_magic_command_selects_caption_mode(tmp_path):
|
||||||
|
image_path = tmp_path / "photo.jpg"
|
||||||
|
image_path.write_bytes(b"test image")
|
||||||
|
|
||||||
|
with patch("photools3.cli.process_photo") as process_photo:
|
||||||
|
result = runner.invoke(
|
||||||
|
app, ["metadata-magic", str(image_path), "--mode", "captions"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
process_photo.assert_called_once_with(image_path, ProcessingMode.CAPTIONS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_magic_command_batches_multiple_files(tmp_path):
|
||||||
|
paths = [tmp_path / "one.jpg", tmp_path / "two.jpg"]
|
||||||
|
for path in paths:
|
||||||
|
path.write_bytes(b"test image")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("photools3.cli.process_photo") as process_photo,
|
||||||
|
patch("photools3.cli.process_batch") as process_batch,
|
||||||
|
):
|
||||||
|
process_batch.return_value = [_success_outcome(path) for path in paths]
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["metadata-magic", *(str(path) for path in paths), "--mode", "keywords"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
process_photo.assert_not_called()
|
||||||
|
process_batch.assert_called_once_with(paths, ProcessingMode.KEYWORDS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_magic_command_expands_directories_recursively(tmp_path):
|
||||||
|
root = tmp_path / "photos"
|
||||||
|
nested = root / "nested"
|
||||||
|
nested.mkdir(parents=True)
|
||||||
|
top_level_photo = root / "one.jpg"
|
||||||
|
nested_photo = nested / "two.png"
|
||||||
|
sidecar = root / "one.jpg.xmp"
|
||||||
|
for path in (top_level_photo, nested_photo, sidecar):
|
||||||
|
path.write_bytes(b"placeholder")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("photools3.cli.process_photo") as process_photo,
|
||||||
|
patch("photools3.cli.process_batch") as process_batch,
|
||||||
|
):
|
||||||
|
process_batch.return_value = [
|
||||||
|
_success_outcome(nested_photo),
|
||||||
|
_success_outcome(top_level_photo),
|
||||||
|
]
|
||||||
|
result = runner.invoke(
|
||||||
|
app, ["metadata-magic", str(root), "--recursive", "--mode", "both"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
process_photo.assert_not_called()
|
||||||
|
process_batch.assert_called_once_with(
|
||||||
|
[nested_photo, top_level_photo], ProcessingMode.BOTH
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_keywords_command_file_not_found(caplog):
|
def test_metadata_magic_command_does_not_recurse_by_default(tmp_path):
|
||||||
"""Test the 'keywords' command with a non-existent file path."""
|
root = tmp_path / "photos"
|
||||||
non_existent_file = Path("non_existent_file.jpg")
|
nested = root / "nested"
|
||||||
|
nested.mkdir(parents=True)
|
||||||
|
top_level_photo = root / "one.jpg"
|
||||||
|
nested_photo = nested / "two.jpg"
|
||||||
|
top_level_photo.write_bytes(b"placeholder")
|
||||||
|
nested_photo.write_bytes(b"placeholder")
|
||||||
|
|
||||||
with caplog.at_level(logging.ERROR, logger=CLI_LOGGER):
|
with (
|
||||||
result = runner.invoke(app, ["keywords", str(non_existent_file)])
|
patch("photools3.cli.process_photo") as process_photo,
|
||||||
|
patch("photools3.cli.process_batch") as process_batch,
|
||||||
|
):
|
||||||
|
result = runner.invoke(app, ["metadata-magic", str(root)])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
process_photo.assert_called_once_with(top_level_photo, ProcessingMode.BOTH)
|
||||||
|
process_batch.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_magic_command_returns_failure_if_any_batch_item_fails(tmp_path):
|
||||||
|
paths = [tmp_path / "one.jpg", tmp_path / "two.jpg"]
|
||||||
|
for path in paths:
|
||||||
|
path.write_bytes(b"test image")
|
||||||
|
|
||||||
|
with patch("photools3.cli.process_batch") as process_batch:
|
||||||
|
process_batch.return_value = [
|
||||||
|
_success_outcome(paths[0]),
|
||||||
|
SimpleNamespace(
|
||||||
|
path=paths[1], succeeded=False, error="RuntimeError: write failed"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
result = runner.invoke(app, ["metadata-magic", *(str(path) for path in paths)])
|
||||||
|
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert f"Error: File '{non_existent_file}' does not exist." in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_keywords_command_error_during_processing(mock_photo_logic, dummy_file):
|
def test_metadata_magic_command_returns_failure_for_single_photo_error(tmp_path):
|
||||||
"""Test the 'keywords' command when an exception occurs during processing."""
|
image_path = tmp_path / "photo.jpg"
|
||||||
# Configure the session mock to raise an exception
|
image_path.write_bytes(b"test image")
|
||||||
mock_photo_logic["session"].call.side_effect = Exception("LLM error")
|
|
||||||
|
|
||||||
result = runner.invoke(app, ["keywords", str(dummy_file)])
|
with patch("photools3.cli.process_photo", side_effect=RuntimeError("LLM error")):
|
||||||
|
result = runner.invoke(app, ["metadata-magic", str(image_path)])
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_keywords_alias_is_not_registered():
|
||||||
|
result = runner.invoke(app, ["keywords", "photo.jpg"])
|
||||||
|
|
||||||
|
assert result.exit_code == 2
|
||||||
|
assert "No such command 'keywords'" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_old_process_command_name_is_not_registered():
|
||||||
|
result = runner.invoke(app, ["process", "photo.jpg"])
|
||||||
|
|
||||||
|
assert result.exit_code == 2
|
||||||
|
assert "No such command 'process'" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_magic_command_rejects_empty_directory(tmp_path):
|
||||||
|
with (
|
||||||
|
patch("photools3.cli.process_photo") as process_photo,
|
||||||
|
patch("photools3.cli.process_batch") as process_batch,
|
||||||
|
):
|
||||||
|
result = runner.invoke(app, ["metadata-magic", str(tmp_path)])
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
process_photo.assert_not_called()
|
||||||
|
process_batch.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_quality_command_closes_photo_and_session(tmp_path):
|
||||||
|
image_path = tmp_path / "photo.jpg"
|
||||||
|
image_path.write_bytes(b"test image")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("photools3.cli.LlmSessionQuality") as session_class,
|
||||||
|
patch("photools3.cli.Photo") as photo_class,
|
||||||
|
):
|
||||||
|
result = runner.invoke(app, ["quality", str(image_path)])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
session_class.return_value.__enter__.return_value.call.assert_called_once_with(
|
||||||
|
[photo_class.return_value.__enter__.return_value]
|
||||||
|
)
|
||||||
|
session_class.return_value.__exit__.assert_called_once()
|
||||||
|
photo_class.return_value.__exit__.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_quality_command_reports_error_and_exits(tmp_path):
|
||||||
|
image_path = tmp_path / "photo.jpg"
|
||||||
|
|
||||||
|
with patch("photools3.cli.Photo", side_effect=FileNotFoundError(image_path)):
|
||||||
|
result = runner.invoke(app, ["quality", str(image_path)])
|
||||||
|
|
||||||
# Note: cli.py raises typer.Exit(code=1) before logging the
|
|
||||||
# "Error processing image: ..." message (the logger.error call is dead
|
|
||||||
# code after the raise), so only the exit code can be asserted here.
|
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
|
|
||||||
|
|
||||||
def test_version_command():
|
def test_version_command():
|
||||||
"""Test the --version option."""
|
|
||||||
result = runner.invoke(app, ["--version"])
|
result = runner.invoke(app, ["--version"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|||||||
Reference in New Issue
Block a user