import initial
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
# 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)"
|
||||
```
|
||||
Reference in New Issue
Block a user