Files
logimage-generator/docs/superpowers/plans/2026-05-20-pexels-image-source.md
T
Vincent Bourdon 5a03f8a38d import initial
2026-06-10 10:21:18 +02:00

11 KiB
Raw Blame History

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.py pour 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)"