342 lines
11 KiB
Markdown
342 lines
11 KiB
Markdown
# 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)"
|
||
```
|