# 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` : ```python 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** ```bash 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` : ```python 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** ```bash 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** ```bash 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` : ```python 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** ```bash 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` : ```python 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** ```bash pytest tests/cli/test_main_routing.py -v ``` Attendu : 4 tests PASSED - [ ] **Step 5 : Lancer la suite complète** ```bash 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** ```bash 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)" ```