11 KiB
Pexels Image Source Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ajouter PexelsImageSource et router automatiquement vers Pexels ou Unsplash selon les clés d'API disponibles dans l'environnement.
Architecture: Le port ImageSource (ABC) existe déjà. On ajoute une implémentation PexelsImageSource dans l'infrastructure, puis on modifie main.py pour instancier le bon provider selon PEXELS_API_KEY (prioritaire) ou UNSPLASH_ACCESS_KEY.
Tech Stack: Python, httpx, pytest, pytest-mock
Fichiers concernés
| Action | Fichier | Rôle |
|---|---|---|
| Créer | src/logimage/infrastructure/image/pexels_source.py |
Implémentation Pexels de ImageSource |
| Créer | tests/infrastructure/test_pexels_source.py |
Tests d'intégration Pexels |
| Créer | tests/cli/__init__.py |
Package tests CLI |
| Créer | tests/cli/test_main_routing.py |
Tests unitaires du routing CLI |
| Modifier | src/logimage/cli/main.py |
Routing selon les vars d'env |
Task 1 : PexelsImageSource
Fichiers :
-
Créer :
src/logimage/infrastructure/image/pexels_source.py -
Créer :
tests/infrastructure/test_pexels_source.py -
Step 1 : Écrire les tests d'intégration (failing)
Créer tests/infrastructure/test_pexels_source.py :
import os
import pytest
from logimage.infrastructure.image.pexels_source import PexelsImageSource
from logimage.domain.value_objects.image_data import ImageData
@pytest.mark.integration
def test_fetch_returns_image_data() -> None:
api_key = os.environ.get("PEXELS_API_KEY")
if not api_key:
pytest.skip("PEXELS_API_KEY not set")
source = PexelsImageSource(api_key=api_key)
result = source.fetch()
assert isinstance(result, ImageData)
assert len(result.content) > 1000
assert isinstance(result.title, str)
@pytest.mark.integration
def test_fetch_with_theme_returns_image_data() -> None:
api_key = os.environ.get("PEXELS_API_KEY")
if not api_key:
pytest.skip("PEXELS_API_KEY not set")
source = PexelsImageSource(api_key=api_key)
result = source.fetch(theme="forest")
assert isinstance(result, ImageData)
assert len(result.content) > 1000
assert isinstance(result.title, str)
- Step 2 : Vérifier que les tests échouent
pytest tests/infrastructure/test_pexels_source.py -v -m integration
Attendu : ImportError: cannot import name 'PexelsImageSource'
- Step 3 : Implémenter PexelsImageSource
Créer src/logimage/infrastructure/image/pexels_source.py :
import httpx
from logimage.domain.ports.image_source import ImageSource
from logimage.domain.value_objects.image_data import ImageData
_SEARCH_URL = "https://api.pexels.com/v1/search"
_CURATED_URL = "https://api.pexels.com/v1/curated"
class PexelsImageSource(ImageSource):
def __init__(self, api_key: str) -> None:
self._api_key = api_key
def fetch(self, theme: str | None = None) -> ImageData:
headers = {"Authorization": self._api_key}
params: dict[str, str | int] = {"per_page": 1}
if theme:
url = _SEARCH_URL
params["query"] = theme
else:
url = _CURATED_URL
with httpx.Client(timeout=30.0) as client:
response = client.get(url, headers=headers, params=params)
response.raise_for_status()
photos = response.json()["photos"]
if not photos:
raise ValueError(f"No photos found for theme={theme!r}")
photo = photos[0]
image_url = photo["src"]["large"]
title: str = photo.get("alt") or theme or "logimage"
image_response = client.get(image_url, timeout=60.0)
image_response.raise_for_status()
return ImageData(content=image_response.content, title=title)
- Step 4 : Vérifier que les tests passent
pytest tests/infrastructure/test_pexels_source.py -v -m integration
Attendu : 2 tests PASSED (ou SKIPPED si PEXELS_API_KEY non définie)
- Step 5 : Commit
git add src/logimage/infrastructure/image/pexels_source.py tests/infrastructure/test_pexels_source.py
git commit -m "feat: add PexelsImageSource"
Task 2 : Routing CLI
Fichiers :
-
Modifier :
src/logimage/cli/main.py -
Créer :
tests/cli/__init__.py -
Créer :
tests/cli/test_main_routing.py -
Step 1 : Écrire les tests unitaires du routing (failing)
Créer tests/cli/__init__.py (vide).
Créer tests/cli/test_main_routing.py :
import pytest
from unittest.mock import patch
from logimage.cli.main import main
from logimage.infrastructure.image.pexels_source import PexelsImageSource
from logimage.infrastructure.image.unsplash_source import UnsplashImageSource
def test_pexels_key_uses_pexels_source(monkeypatch) -> None:
monkeypatch.setenv("PEXELS_API_KEY", "pexels-test-key")
monkeypatch.delenv("UNSPLASH_ACCESS_KEY", raising=False)
with patch("logimage.cli.main.GeneratePuzzlesUseCase") as mock_uc, \
patch("logimage.cli.main.load_dotenv"), \
patch("sys.argv", ["logimage", "--count", "1"]):
mock_uc.return_value.execute.return_value = None
main()
assert isinstance(mock_uc.call_args.kwargs["image_source"], PexelsImageSource)
def test_unsplash_key_uses_unsplash_source(monkeypatch) -> None:
monkeypatch.delenv("PEXELS_API_KEY", raising=False)
monkeypatch.setenv("UNSPLASH_ACCESS_KEY", "unsplash-test-key")
with patch("logimage.cli.main.GeneratePuzzlesUseCase") as mock_uc, \
patch("logimage.cli.main.load_dotenv"), \
patch("sys.argv", ["logimage", "--count", "1"]):
mock_uc.return_value.execute.return_value = None
main()
assert isinstance(mock_uc.call_args.kwargs["image_source"], UnsplashImageSource)
def test_pexels_takes_priority_over_unsplash(monkeypatch) -> None:
monkeypatch.setenv("PEXELS_API_KEY", "pexels-test-key")
monkeypatch.setenv("UNSPLASH_ACCESS_KEY", "unsplash-test-key")
with patch("logimage.cli.main.GeneratePuzzlesUseCase") as mock_uc, \
patch("logimage.cli.main.load_dotenv"), \
patch("sys.argv", ["logimage", "--count", "1"]):
mock_uc.return_value.execute.return_value = None
main()
assert isinstance(mock_uc.call_args.kwargs["image_source"], PexelsImageSource)
def test_no_key_exits_with_error(monkeypatch, capsys) -> None:
monkeypatch.delenv("PEXELS_API_KEY", raising=False)
monkeypatch.delenv("UNSPLASH_ACCESS_KEY", raising=False)
with patch("logimage.cli.main.load_dotenv"), \
patch("sys.argv", ["logimage", "--count", "1"]):
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 1
captured = capsys.readouterr()
assert "PEXELS_API_KEY" in captured.err
assert "UNSPLASH_ACCESS_KEY" in captured.err
- Step 2 : Vérifier que les tests échouent
pytest tests/cli/test_main_routing.py -v
Attendu : tests échouent (la logique de routing n'est pas encore implémentée)
- Step 3 : Modifier
main.pypour le routing
Remplacer dans src/logimage/cli/main.py le bloc d'import et de construction du image_source :
import argparse
import sys
from pathlib import Path
from dotenv import load_dotenv
import os
from logimage.application.use_cases.generate_puzzles import (
GeneratePuzzlesRequest,
GeneratePuzzlesUseCase,
)
from logimage.infrastructure.image.pexels_source import PexelsImageSource
from logimage.infrastructure.image.unsplash_source import UnsplashImageSource
from logimage.infrastructure.image.pillow_converter import PillowImageConverter
from logimage.infrastructure.pdf.reportlab_exporter import ReportLabPdfExporter
def _parse_size(value: str) -> tuple[int, int]:
parts = value.lower().split("x")
if len(parts) != 2:
raise argparse.ArgumentTypeError(f"Size must be WxH (e.g. 20x15), got '{value}'")
try:
w, h = int(parts[0]), int(parts[1])
except ValueError:
raise argparse.ArgumentTypeError(f"Width and height must be integers, got '{value}'")
return w, h
def main() -> None:
load_dotenv()
parser = argparse.ArgumentParser(
description="Generate nonogram (logimage) puzzles as a printable PDF."
)
parser.add_argument("--theme", help="Image search keyword (default: random)")
parser.add_argument(
"--difficulty",
choices=["easy", "medium", "hard"],
default="medium",
help="Grid size preset: easy=10×10, medium=15×15, hard=20×20 (default: medium)",
)
parser.add_argument(
"--size",
type=_parse_size,
metavar="WxH",
help="Custom grid size, e.g. 20x25 (overrides --difficulty)",
)
parser.add_argument("--count", type=int, default=1, help="Number of puzzles (default: 1)")
parser.add_argument(
"--solution",
action="store_true",
help="Append a solution page after each puzzle",
)
parser.add_argument(
"--output",
type=Path,
default=Path("puzzles.pdf"),
metavar="PATH",
help="Output PDF path (default: puzzles.pdf)",
)
args = parser.parse_args()
pexels_key = os.environ.get("PEXELS_API_KEY")
unsplash_key = os.environ.get("UNSPLASH_ACCESS_KEY")
if pexels_key:
image_source = PexelsImageSource(api_key=pexels_key)
elif unsplash_key:
image_source = UnsplashImageSource(api_key=unsplash_key)
else:
print(
"Error: no image API key found.\n"
"Set PEXELS_API_KEY or UNSPLASH_ACCESS_KEY in your .env file.",
file=sys.stderr,
)
sys.exit(1)
use_case = GeneratePuzzlesUseCase(
image_source=image_source,
image_converter=PillowImageConverter(),
pdf_exporter=ReportLabPdfExporter(),
)
request = GeneratePuzzlesRequest(
count=args.count,
difficulty=args.difficulty,
size=args.size,
theme=args.theme,
output_path=args.output,
with_solution=args.solution,
)
print(f"Generating {args.count} puzzle(s)…")
use_case.execute(request)
print(f"Done! PDF saved to: {args.output}")
- Step 4 : Vérifier que les tests passent
pytest tests/cli/test_main_routing.py -v
Attendu : 4 tests PASSED
- Step 5 : Lancer la suite complète
pytest tests/ -v --ignore=tests/infrastructure/test_pexels_source.py --ignore=tests/infrastructure/test_unsplash_source.py
Attendu : tous les tests passent (les tests d'intégration réseau sont exclus)
- Step 6 : Commit
git add src/logimage/cli/main.py tests/cli/__init__.py tests/cli/test_main_routing.py
git commit -m "feat: route image source based on available API key (Pexels first, Unsplash fallback)"