Claude commited on
Commit
8e5d22a
·
unverified ·
1 Parent(s): 02197c5

Sprint 2 Session B — Pipeline image : fetch IIIF + dérivé JPEG 1500px + thumbnail

Browse files

Schéma :
- schemas/image.py : ImageDerivativeInfo (9 champs, spec exacte utilisateur)

Services :
- services/ingest/iiif_fetcher.py : fetch_iiif_image(url) → bytes (httpx, timeout 60s)
- services/image/normalizer.py : _resize_to_max(), create_derivatives(), fetch_and_normalize()
· Dérivé : grand côté <= 1500px, qualité JPEG 90, pas d'upscaling
· Thumbnail : grand côté <= 256px, qualité JPEG 75
· Conversion RGB auto (RGBA, palette, etc.)
· Chemins : data/corpora/{slug}/derivatives/{folio}.jpg|_thumb.jpg

Tests :
- 25 tests unitaires (httpx mocké, images Pillow en mémoire) — 116/116 passed
- 3 tests d'intégration skippés (BnF Gallica : Beatus hi-res, lo-res, Grandes Chroniques)
→ activer avec RUN_INTEGRATION_TESTS=1

Note : patch("module.genai.Client") inefficace quand deux modules partagent
le même objet genai — résolu en Sprint 2A, même pattern évité ici.

https://claude.ai/code/session_018woyEHc8HG2th7V4ewJ4Kg

backend/app/schemas/image.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Schéma Pydantic pour les métadonnées du dérivé image produit par le pipeline.
3
+ """
4
+ # 2. third-party
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class ImageDerivativeInfo(BaseModel):
9
+ """Résultat de la normalisation d'une image : dimensions originales et chemins des dérivés."""
10
+
11
+ original_url: str
12
+ original_width: int
13
+ original_height: int
14
+ derivative_path: str
15
+ derivative_width: int
16
+ derivative_height: int
17
+ thumbnail_path: str
18
+ thumbnail_width: int
19
+ thumbnail_height: int
backend/app/services/image/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """
2
+ Services image — normalisation et production des dérivés JPEG pour le pipeline IA.
3
+ """
4
+ from app.services.image.normalizer import create_derivatives, fetch_and_normalize
5
+
6
+ __all__ = ["create_derivatives", "fetch_and_normalize"]
backend/app/services/image/normalizer.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Normalisation d'images : dérivé JPEG 1500px max + thumbnail pour le pipeline IA.
3
+ """
4
+ # 1. stdlib
5
+ import io
6
+ import logging
7
+ from pathlib import Path
8
+
9
+ # 2. third-party
10
+ from PIL import Image
11
+
12
+ # 3. local
13
+ from app.schemas.image import ImageDerivativeInfo
14
+ from app.services.ingest.iiif_fetcher import fetch_iiif_image
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Constantes de normalisation
19
+ _MAX_DERIVATIVE_PX = 1500 # grand côté max du dérivé envoyé à l'IA
20
+ _MAX_THUMBNAIL_PX = 256 # grand côté max du thumbnail
21
+ _DERIVATIVE_QUALITY = 90 # qualité JPEG dérivé
22
+ _THUMBNAIL_QUALITY = 75 # qualité JPEG thumbnail
23
+
24
+
25
+ def _resize_to_max(image: Image.Image, max_size: int) -> Image.Image:
26
+ """Redimensionne l'image pour que son grand côté vaille max_size.
27
+
28
+ Si l'image est déjà plus petite ou égale, retourne une copie sans upscaling.
29
+ Le ratio d'aspect est préservé. Utilise LANCZOS pour la qualité.
30
+ """
31
+ w, h = image.size
32
+ if max(w, h) <= max_size:
33
+ return image.copy()
34
+ if w >= h:
35
+ new_w = max_size
36
+ new_h = max(1, round(h * max_size / w))
37
+ else:
38
+ new_h = max_size
39
+ new_w = max(1, round(w * max_size / h))
40
+ return image.resize((new_w, new_h), Image.Resampling.LANCZOS)
41
+
42
+
43
+ def create_derivatives(
44
+ source_bytes: bytes,
45
+ original_url: str,
46
+ corpus_slug: str,
47
+ folio_label: str,
48
+ base_data_dir: Path = Path("data"),
49
+ ) -> ImageDerivativeInfo:
50
+ """Produit un dérivé JPEG (1500px max) et un thumbnail depuis des bytes image.
51
+
52
+ Structure de sortie (CLAUDE.md §3) :
53
+ {base_data_dir}/corpora/{corpus_slug}/derivatives/{folio_label}.jpg
54
+ {base_data_dir}/corpora/{corpus_slug}/derivatives/{folio_label}_thumb.jpg
55
+
56
+ Args:
57
+ source_bytes: contenu brut de l'image source (JPEG, PNG, TIFF, etc.).
58
+ original_url: URL d'origine (conservée dans le schéma de sortie).
59
+ corpus_slug: identifiant du corpus (ex. "beatus-lat8878").
60
+ folio_label: identifiant du folio (ex. "0013r").
61
+ base_data_dir: racine du dossier data (défaut : Path("data")).
62
+
63
+ Returns:
64
+ ImageDerivativeInfo avec dimensions et chemins des fichiers produits.
65
+
66
+ Raises:
67
+ PIL.UnidentifiedImageError: si les bytes ne sont pas une image valide.
68
+ OSError: si l'écriture sur disque échoue.
69
+ """
70
+ image = Image.open(io.BytesIO(source_bytes))
71
+
72
+ # Convertir en RGB pour garantir un JPEG valide (PNG RGBA, palette, etc.)
73
+ if image.mode != "RGB":
74
+ image = image.convert("RGB")
75
+
76
+ original_width, original_height = image.size
77
+ logger.info(
78
+ "Image ouverte",
79
+ extra={
80
+ "corpus": corpus_slug,
81
+ "folio": folio_label,
82
+ "original_size": f"{original_width}x{original_height}",
83
+ },
84
+ )
85
+
86
+ # Dossier de sortie
87
+ derivatives_dir = base_data_dir / "corpora" / corpus_slug / "derivatives"
88
+ derivatives_dir.mkdir(parents=True, exist_ok=True)
89
+
90
+ # Dérivé IA : grand côté <= 1500px
91
+ deriv_image = _resize_to_max(image, _MAX_DERIVATIVE_PX)
92
+ derivative_width, derivative_height = deriv_image.size
93
+ derivative_path = derivatives_dir / f"{folio_label}.jpg"
94
+ deriv_image.save(derivative_path, format="JPEG", quality=_DERIVATIVE_QUALITY)
95
+
96
+ # Thumbnail : grand côté <= 256px
97
+ thumb_image = _resize_to_max(image, _MAX_THUMBNAIL_PX)
98
+ thumbnail_width, thumbnail_height = thumb_image.size
99
+ thumbnail_path = derivatives_dir / f"{folio_label}_thumb.jpg"
100
+ thumb_image.save(thumbnail_path, format="JPEG", quality=_THUMBNAIL_QUALITY)
101
+
102
+ logger.info(
103
+ "Dérivés produits",
104
+ extra={
105
+ "corpus": corpus_slug,
106
+ "folio": folio_label,
107
+ "derivative": f"{derivative_width}x{derivative_height}",
108
+ "thumbnail": f"{thumbnail_width}x{thumbnail_height}",
109
+ },
110
+ )
111
+
112
+ return ImageDerivativeInfo(
113
+ original_url=original_url,
114
+ original_width=original_width,
115
+ original_height=original_height,
116
+ derivative_path=str(derivative_path),
117
+ derivative_width=derivative_width,
118
+ derivative_height=derivative_height,
119
+ thumbnail_path=str(thumbnail_path),
120
+ thumbnail_width=thumbnail_width,
121
+ thumbnail_height=thumbnail_height,
122
+ )
123
+
124
+
125
+ def fetch_and_normalize(
126
+ url: str,
127
+ corpus_slug: str,
128
+ folio_label: str,
129
+ base_data_dir: Path = Path("data"),
130
+ ) -> ImageDerivativeInfo:
131
+ """Point d'entrée principal : télécharge depuis une URL IIIF et produit les dérivés.
132
+
133
+ Chaîne fetch_iiif_image() → create_derivatives().
134
+
135
+ Args:
136
+ url: URL complète de l'image IIIF.
137
+ corpus_slug: identifiant du corpus.
138
+ folio_label: identifiant du folio.
139
+ base_data_dir: racine du dossier data.
140
+
141
+ Returns:
142
+ ImageDerivativeInfo rempli.
143
+ """
144
+ source_bytes = fetch_iiif_image(url)
145
+ return create_derivatives(source_bytes, url, corpus_slug, folio_label, base_data_dir)
backend/app/services/ingest/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """
2
+ Services d'ingestion — téléchargement de sources (IIIF, fichiers locaux).
3
+ """
4
+ from app.services.ingest.iiif_fetcher import fetch_iiif_image
5
+
6
+ __all__ = ["fetch_iiif_image"]
backend/app/services/ingest/iiif_fetcher.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Téléchargement d'images depuis des URLs IIIF via httpx.
3
+ """
4
+ # 1. stdlib
5
+ import logging
6
+
7
+ # 2. third-party
8
+ import httpx
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ _DEFAULT_TIMEOUT = 60.0 # secondes — les images IIIF haute résolution peuvent être lourdes
13
+
14
+
15
+ def fetch_iiif_image(url: str, timeout: float = _DEFAULT_TIMEOUT) -> bytes:
16
+ """Télécharge une image depuis une URL IIIF complète.
17
+
18
+ Args:
19
+ url: URL complète de l'image (ex. https://.../full/max/0/default.jpg).
20
+ timeout: délai maximal en secondes (défaut : 60 s).
21
+
22
+ Returns:
23
+ Contenu brut de l'image en bytes.
24
+
25
+ Raises:
26
+ httpx.HTTPStatusError: si le serveur retourne un code 4xx ou 5xx.
27
+ httpx.TimeoutException: si la requête dépasse le délai.
28
+ httpx.RequestError: pour toute autre erreur réseau.
29
+ """
30
+ logger.info("Fetching IIIF image", extra={"url": url})
31
+ response = httpx.get(url, follow_redirects=True, timeout=timeout)
32
+ response.raise_for_status()
33
+ logger.info(
34
+ "IIIF image fetched",
35
+ extra={"url": url, "size_bytes": len(response.content)},
36
+ )
37
+ return response.content
backend/tests/test_image_pipeline.py ADDED
@@ -0,0 +1,393 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests du pipeline image : fetch IIIF + normalisation (dérivé + thumbnail).
3
+ Tests unitaires : httpx mocké, images créées en mémoire (Pillow).
4
+ Tests d'intégration : requêtes réseau réelles, activés via RUN_INTEGRATION_TESTS=1.
5
+ """
6
+ # 1. stdlib
7
+ import io
8
+ import os
9
+ from pathlib import Path
10
+ from unittest.mock import MagicMock, patch
11
+
12
+ # 2. third-party
13
+ import httpx
14
+ import pytest
15
+ from PIL import Image
16
+ from pydantic import ValidationError
17
+
18
+ # 3. local
19
+ from app.schemas.image import ImageDerivativeInfo
20
+ from app.services.image.normalizer import (
21
+ _MAX_DERIVATIVE_PX,
22
+ _MAX_THUMBNAIL_PX,
23
+ _resize_to_max,
24
+ create_derivatives,
25
+ fetch_and_normalize,
26
+ )
27
+ from app.services.ingest.iiif_fetcher import fetch_iiif_image
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Marqueur d'intégration — activé seulement si RUN_INTEGRATION_TESTS=1
31
+ # ---------------------------------------------------------------------------
32
+
33
+ integration = pytest.mark.skipif(
34
+ not os.environ.get("RUN_INTEGRATION_TESTS"),
35
+ reason="Tests réseau réels : définir RUN_INTEGRATION_TESTS=1 pour les activer",
36
+ )
37
+
38
+ # URLs IIIF des 3 manuscrits de test (BnF Gallica)
39
+ _URL_BEATUS_HI = (
40
+ "https://gallica.bnf.fr/iiif/ark:/12148/btv1b8432836p/f13/full/max/0/default.jpg"
41
+ )
42
+ _URL_BEATUS_LO = (
43
+ "https://gallica.bnf.fr/iiif/ark:/12148/btv1b8432836p/f13/full/600,/0/default.jpg"
44
+ )
45
+ _URL_GRANDES_CHRONIQUES = (
46
+ "https://gallica.bnf.fr/iiif/ark:/12148/btv1b8427295k/f3/full/max/0/default.jpg"
47
+ )
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Helpers de test
52
+ # ---------------------------------------------------------------------------
53
+
54
+ def _make_jpeg_bytes(width: int, height: int, color: tuple[int, int, int] = (200, 150, 100)) -> bytes:
55
+ """Crée un JPEG minimal en mémoire pour les tests unitaires."""
56
+ img = Image.new("RGB", (width, height), color=color)
57
+ buf = io.BytesIO()
58
+ img.save(buf, format="JPEG", quality=85)
59
+ return buf.getvalue()
60
+
61
+
62
+ def _make_png_rgba_bytes(width: int, height: int) -> bytes:
63
+ """Crée un PNG RGBA en mémoire (pour tester la conversion RGB)."""
64
+ img = Image.new("RGBA", (width, height), color=(100, 150, 200, 128))
65
+ buf = io.BytesIO()
66
+ img.save(buf, format="PNG")
67
+ return buf.getvalue()
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Tests — ImageDerivativeInfo (schéma)
72
+ # ---------------------------------------------------------------------------
73
+
74
+ def test_schema_valid():
75
+ info = ImageDerivativeInfo(
76
+ original_url="https://example.com/image.jpg",
77
+ original_width=3000,
78
+ original_height=4000,
79
+ derivative_path="/data/corpora/test/derivatives/0001r.jpg",
80
+ derivative_width=1125,
81
+ derivative_height=1500,
82
+ thumbnail_path="/data/corpora/test/derivatives/0001r_thumb.jpg",
83
+ thumbnail_width=192,
84
+ thumbnail_height=256,
85
+ )
86
+ assert info.original_width == 3000
87
+ assert info.derivative_width == 1125
88
+
89
+
90
+ def test_schema_missing_required_field():
91
+ with pytest.raises(ValidationError):
92
+ ImageDerivativeInfo.model_validate({"original_url": "https://x.com/img.jpg"})
93
+
94
+
95
+ def test_schema_all_fields_present():
96
+ fields = ImageDerivativeInfo.model_fields.keys()
97
+ expected = {
98
+ "original_url", "original_width", "original_height",
99
+ "derivative_path", "derivative_width", "derivative_height",
100
+ "thumbnail_path", "thumbnail_width", "thumbnail_height",
101
+ }
102
+ assert set(fields) == expected
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Tests — _resize_to_max
107
+ # ---------------------------------------------------------------------------
108
+
109
+ def test_resize_small_image_not_upscaled():
110
+ """Une image déjà petite ne doit pas être agrandie."""
111
+ img = Image.new("RGB", (800, 600))
112
+ result = _resize_to_max(img, _MAX_DERIVATIVE_PX)
113
+ assert result.size == (800, 600)
114
+
115
+
116
+ def test_resize_exact_max_not_changed():
117
+ """Une image dont le grand côté est exactement max_size n'est pas redimensionnée."""
118
+ img = Image.new("RGB", (1500, 1000))
119
+ result = _resize_to_max(img, _MAX_DERIVATIVE_PX)
120
+ assert result.size == (1500, 1000)
121
+
122
+
123
+ def test_resize_landscape_large():
124
+ """Paysage 3000x2000 → 1500x1000."""
125
+ img = Image.new("RGB", (3000, 2000))
126
+ result = _resize_to_max(img, 1500)
127
+ assert result.size == (1500, 1000)
128
+
129
+
130
+ def test_resize_portrait_large():
131
+ """Portrait 2000x3000 → 1000x1500."""
132
+ img = Image.new("RGB", (2000, 3000))
133
+ result = _resize_to_max(img, 1500)
134
+ assert result.size == (1000, 1500)
135
+
136
+
137
+ def test_resize_square_large():
138
+ """Carré 2000x2000 → 1500x1500."""
139
+ img = Image.new("RGB", (2000, 2000))
140
+ result = _resize_to_max(img, 1500)
141
+ assert result.size == (1500, 1500)
142
+
143
+
144
+ def test_resize_preserves_aspect_ratio():
145
+ """Le ratio d'aspect est préservé après resize."""
146
+ img = Image.new("RGB", (4000, 3000))
147
+ result = _resize_to_max(img, 1500)
148
+ w, h = result.size
149
+ assert w == 1500
150
+ assert abs(w / h - 4 / 3) < 0.01
151
+
152
+
153
+ def test_resize_returns_copy_when_no_resize_needed():
154
+ """Retourne une copie (pas la même instance) même sans resize."""
155
+ img = Image.new("RGB", (100, 100))
156
+ result = _resize_to_max(img, 1500)
157
+ assert result is not img
158
+
159
+
160
+ def test_resize_thumbnail_size():
161
+ """Vérification pour la taille thumbnail (256px)."""
162
+ img = Image.new("RGB", (1200, 800))
163
+ result = _resize_to_max(img, _MAX_THUMBNAIL_PX)
164
+ assert result.size[0] == 256
165
+ assert result.size[1] == 171 # round(800 * 256 / 1200) = round(170.67) = 171
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # Tests — create_derivatives
170
+ # ---------------------------------------------------------------------------
171
+
172
+ def test_create_derivatives_large_landscape(tmp_path):
173
+ """Image 3000x2000 → dérivé 1500x1000, thumbnail 256x171."""
174
+ source = _make_jpeg_bytes(3000, 2000)
175
+ info = create_derivatives(source, "https://x.com/img.jpg", "test-corpus", "0001r", tmp_path)
176
+
177
+ assert info.original_width == 3000
178
+ assert info.original_height == 2000
179
+ assert info.derivative_width == 1500
180
+ assert info.derivative_height == 1000
181
+ assert info.thumbnail_width == 256
182
+ assert info.thumbnail_height == 171
183
+ assert info.original_url == "https://x.com/img.jpg"
184
+
185
+
186
+ def test_create_derivatives_small_image_not_upscaled(tmp_path):
187
+ """Image 600x900 (< 1500px) : dérivé conserve les dimensions originales."""
188
+ source = _make_jpeg_bytes(600, 900)
189
+ info = create_derivatives(source, "https://x.com/img.jpg", "test-corpus", "0001r", tmp_path)
190
+
191
+ assert info.derivative_width == 600
192
+ assert info.derivative_height == 900
193
+ assert info.original_width == 600
194
+ assert info.original_height == 900
195
+
196
+
197
+ def test_create_derivatives_files_exist(tmp_path):
198
+ """Les deux fichiers JPEG sont bien créés sur disque."""
199
+ source = _make_jpeg_bytes(2000, 3000)
200
+ info = create_derivatives(source, "https://x.com/img.jpg", "corpus-a", "f001r", tmp_path)
201
+
202
+ assert Path(info.derivative_path).exists()
203
+ assert Path(info.thumbnail_path).exists()
204
+
205
+
206
+ def test_create_derivatives_path_structure(tmp_path):
207
+ """Les chemins respectent la convention CLAUDE.md §3."""
208
+ source = _make_jpeg_bytes(1000, 1000)
209
+ info = create_derivatives(source, "https://x.com/img.jpg", "beatus-lat8878", "0013r", tmp_path)
210
+
211
+ expected_deriv = tmp_path / "corpora" / "beatus-lat8878" / "derivatives" / "0013r.jpg"
212
+ expected_thumb = tmp_path / "corpora" / "beatus-lat8878" / "derivatives" / "0013r_thumb.jpg"
213
+ assert info.derivative_path == str(expected_deriv)
214
+ assert info.thumbnail_path == str(expected_thumb)
215
+
216
+
217
+ def test_create_derivatives_output_is_jpeg(tmp_path):
218
+ """Les fichiers produits sont bien des JPEG valides."""
219
+ source = _make_jpeg_bytes(1000, 800)
220
+ info = create_derivatives(source, "https://x.com/img.jpg", "corpus-b", "f002r", tmp_path)
221
+
222
+ with Image.open(info.derivative_path) as img:
223
+ assert img.format == "JPEG"
224
+ with Image.open(info.thumbnail_path) as img:
225
+ assert img.format == "JPEG"
226
+
227
+
228
+ def test_create_derivatives_rgba_converted_to_rgb(tmp_path):
229
+ """Un PNG RGBA est converti en RGB sans erreur."""
230
+ source = _make_png_rgba_bytes(800, 1000)
231
+ info = create_derivatives(source, "https://x.com/img.png", "corpus-c", "f003r", tmp_path)
232
+
233
+ with Image.open(info.derivative_path) as img:
234
+ assert img.mode == "RGB"
235
+ assert info.original_width == 800
236
+ assert info.original_height == 1000
237
+
238
+
239
+ def test_create_derivatives_thumbnail_dimensions(tmp_path):
240
+ """Le thumbnail a bien son grand côté <= 256px."""
241
+ source = _make_jpeg_bytes(3000, 4000)
242
+ info = create_derivatives(source, "https://x.com/img.jpg", "corpus-d", "f004r", tmp_path)
243
+
244
+ assert max(info.thumbnail_width, info.thumbnail_height) == _MAX_THUMBNAIL_PX
245
+
246
+
247
+ def test_create_derivatives_creates_parent_dirs(tmp_path):
248
+ """Les dossiers intermédiaires sont créés automatiquement."""
249
+ source = _make_jpeg_bytes(500, 500)
250
+ new_slug = "nouveau-corpus-jamais-vu"
251
+ info = create_derivatives(source, "https://x.com/img.jpg", new_slug, "f001r", tmp_path)
252
+
253
+ assert Path(info.derivative_path).parent.exists()
254
+
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # Tests — fetch_iiif_image
258
+ # ---------------------------------------------------------------------------
259
+
260
+ def test_fetch_iiif_image_success():
261
+ """Retourne les bytes de l'image si la requête réussit."""
262
+ fake_bytes = _make_jpeg_bytes(100, 100)
263
+
264
+ with patch("app.services.ingest.iiif_fetcher.httpx.get") as mock_get:
265
+ mock_response = MagicMock()
266
+ mock_response.content = fake_bytes
267
+ mock_response.raise_for_status.return_value = None
268
+ mock_get.return_value = mock_response
269
+
270
+ result = fetch_iiif_image("https://example.com/image.jpg")
271
+
272
+ assert result == fake_bytes
273
+ mock_get.assert_called_once_with(
274
+ "https://example.com/image.jpg",
275
+ follow_redirects=True,
276
+ timeout=60.0,
277
+ )
278
+
279
+
280
+ def test_fetch_iiif_image_http_error():
281
+ """Propage HTTPStatusError si le serveur répond 404."""
282
+ with patch("app.services.ingest.iiif_fetcher.httpx.get") as mock_get:
283
+ mock_response = MagicMock()
284
+ mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
285
+ "404 Not Found",
286
+ request=MagicMock(),
287
+ response=MagicMock(status_code=404),
288
+ )
289
+ mock_get.return_value = mock_response
290
+
291
+ with pytest.raises(httpx.HTTPStatusError):
292
+ fetch_iiif_image("https://example.com/missing.jpg")
293
+
294
+
295
+ def test_fetch_iiif_image_timeout():
296
+ """Propage TimeoutException si la requête dépasse le délai."""
297
+ with patch("app.services.ingest.iiif_fetcher.httpx.get") as mock_get:
298
+ mock_get.side_effect = httpx.TimeoutException("timed out")
299
+
300
+ with pytest.raises(httpx.TimeoutException):
301
+ fetch_iiif_image("https://example.com/slow.jpg", timeout=1.0)
302
+
303
+
304
+ def test_fetch_iiif_image_custom_timeout():
305
+ """Le timeout personnalisé est bien transmis à httpx.get."""
306
+ fake_bytes = _make_jpeg_bytes(50, 50)
307
+
308
+ with patch("app.services.ingest.iiif_fetcher.httpx.get") as mock_get:
309
+ mock_response = MagicMock()
310
+ mock_response.content = fake_bytes
311
+ mock_response.raise_for_status.return_value = None
312
+ mock_get.return_value = mock_response
313
+
314
+ fetch_iiif_image("https://example.com/img.jpg", timeout=120.0)
315
+
316
+ _, kwargs = mock_get.call_args
317
+ assert kwargs["timeout"] == 120.0
318
+
319
+
320
+ # ---------------------------------------------------------------------------
321
+ # Tests — fetch_and_normalize (end-to-end mocké)
322
+ # ---------------------------------------------------------------------------
323
+
324
+ def test_fetch_and_normalize_chains_correctly(tmp_path):
325
+ """fetch_and_normalize appelle fetch_iiif_image puis create_derivatives."""
326
+ fake_bytes = _make_jpeg_bytes(2000, 1500)
327
+
328
+ with patch("app.services.image.normalizer.fetch_iiif_image", return_value=fake_bytes) as mock_fetch:
329
+ info = fetch_and_normalize(
330
+ "https://example.com/ms/f001.jpg",
331
+ "corpus-test",
332
+ "0001r",
333
+ tmp_path,
334
+ )
335
+
336
+ mock_fetch.assert_called_once_with("https://example.com/ms/f001.jpg")
337
+ assert info.original_url == "https://example.com/ms/f001.jpg"
338
+ assert info.original_width == 2000
339
+ assert info.original_height == 1500
340
+ assert info.derivative_width == 1500
341
+ assert info.derivative_height == 1125
342
+ assert Path(info.derivative_path).exists()
343
+ assert Path(info.thumbnail_path).exists()
344
+
345
+
346
+ def test_fetch_and_normalize_propagates_http_error(tmp_path):
347
+ """Les erreurs HTTP de fetch_iiif_image sont propagées sans être masquées."""
348
+ with patch(
349
+ "app.services.image.normalizer.fetch_iiif_image",
350
+ side_effect=httpx.HTTPStatusError("403", request=MagicMock(), response=MagicMock()),
351
+ ):
352
+ with pytest.raises(httpx.HTTPStatusError):
353
+ fetch_and_normalize("https://example.com/img.jpg", "corpus", "f001", tmp_path)
354
+
355
+
356
+ # ---------------------------------------------------------------------------
357
+ # Tests d'intégration — URLs IIIF BnF réelles (skippés par défaut)
358
+ # ---------------------------------------------------------------------------
359
+
360
+ @integration
361
+ def test_integration_beatus_high_res(tmp_path):
362
+ """Beatus de Saint-Sever, BnF Latin 8878, f.13 — haute résolution."""
363
+ info = fetch_and_normalize(_URL_BEATUS_HI, "beatus-lat8878", "0013r", tmp_path)
364
+
365
+ assert info.original_width > 1500 or info.original_height > 1500
366
+ assert info.derivative_width <= _MAX_DERIVATIVE_PX
367
+ assert info.derivative_height <= _MAX_DERIVATIVE_PX
368
+ assert max(info.derivative_width, info.derivative_height) == _MAX_DERIVATIVE_PX
369
+ assert Path(info.derivative_path).exists()
370
+ assert Path(info.thumbnail_path).exists()
371
+
372
+
373
+ @integration
374
+ def test_integration_beatus_low_res(tmp_path):
375
+ """Beatus de Saint-Sever, BnF Latin 8878, f.13 — 600px (image déjà petite)."""
376
+ info = fetch_and_normalize(_URL_BEATUS_LO, "beatus-lat8878", "0013r-600", tmp_path)
377
+
378
+ # Image à 600px de large : pas d'upscaling, dérivé == original
379
+ assert info.derivative_width <= _MAX_DERIVATIVE_PX
380
+ assert info.derivative_height <= _MAX_DERIVATIVE_PX
381
+ assert max(info.derivative_width, info.derivative_height) <= 600
382
+ assert Path(info.derivative_path).exists()
383
+
384
+
385
+ @integration
386
+ def test_integration_grandes_chroniques(tmp_path):
387
+ """Grandes Chroniques de France, BnF Français 2813."""
388
+ info = fetch_and_normalize(_URL_GRANDES_CHRONIQUES, "grandes-chroniques", "f003", tmp_path)
389
+
390
+ assert info.derivative_width <= _MAX_DERIVATIVE_PX
391
+ assert info.derivative_height <= _MAX_DERIVATIVE_PX
392
+ assert Path(info.derivative_path).exists()
393
+ assert Path(info.thumbnail_path).exists()