Spaces:
Sleeping
fix(web): valider strictement les payloads Pydantic (max_length, Literal)
Browse filesL'audit a identifié que ``picarones/web/models.py`` n'imposait
aucune borne aux strings ni aux listes — un payload de 1 Go sur
``char_exclude`` ou ``corpus_path`` passait la validation et
consommait de la mémoire pour rien. Les enums (``lang``,
``report_lang``, ``normalization_profile``) étaient typés ``str``
ouvert, donc ``"../../etc"`` était accepté avant d'être passé en
chaîne aux fonctions internes.
Fix : tous les champs ``str`` ont désormais une borne ``max_length``
proportionnée à leur usage attendu (constantes ``_MAX_PATH=1024``,
``_MAX_NAME=256``, ``_MAX_CHAR_EXCLUDE=256``, ``_MAX_PROMPT_FILENAME=256``).
Les listes (``engines``, ``competitors``) ont une borne
``max_length`` de 32. Les énumérations finies sont typées en
``Literal[...]`` pour rejeter au plus tôt :
- ``TesseractLang`` : 18 codes ISO (``fra``, ``lat``, ``eng``, …)
- ``ReportLang`` : ``fr`` ou ``en``
- ``NormalizationProfileId`` : 8 profils Unicode officiels
``BenchmarkRunRequest.competitors`` reçoit ``min_length=1`` —
Pydantic rejette désormais une liste vide en ``422 Unprocessable
Entity`` (code HTTP standard pour payload invalide), ce qui rend
caduque la vérification manuelle dans ``routers/benchmark.py``
(retirée). ``test_run_400_no_competitors`` mis à jour pour
attendre ``422`` au lieu de ``400`` — comportement plus correct
côté HTTP.
Pytest : 3354 passed, 2 skipped, 0 failed. Ruff : All checks passed.
https://claude.ai/code/session_01Hsd7kL8yeCbXn1mA7GQK9L
|
@@ -1,65 +1,120 @@
|
|
| 1 |
"""Modèles Pydantic partagés par les routers FastAPI.
|
| 2 |
|
| 3 |
-
Ces schémas décrivent les payloads des requêtes ``POST`` consommées
|
| 4 |
-
plusieurs endpoints du serveur web. Les sortir d'``app.py``
|
| 5 |
-
chaque routeur de les importer sans dépendance vers
|
| 6 |
-
elle-même.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
-
from
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
class BenchmarkRequest(BaseModel):
|
| 15 |
-
corpus_path: str
|
| 16 |
-
engines: list[str] = ["tesseract"]
|
| 17 |
-
normalization_profile:
|
| 18 |
-
char_exclude: str = ""
|
| 19 |
"""Caractères à ignorer (séparés par virgule, ex: ``"',–"``)."""
|
| 20 |
-
output_dir: str = "./rapports/"
|
| 21 |
-
report_name: str = ""
|
| 22 |
-
lang:
|
| 23 |
-
report_lang:
|
| 24 |
"""Langue du rapport HTML : ``fr`` ou ``en``."""
|
| 25 |
|
| 26 |
|
| 27 |
class HTRUnitedImportRequest(BaseModel):
|
| 28 |
-
entry_id: str
|
| 29 |
-
output_dir: str = "./corpus/"
|
| 30 |
-
max_samples: int = 100
|
| 31 |
|
| 32 |
|
| 33 |
class HuggingFaceImportRequest(BaseModel):
|
| 34 |
-
dataset_id: str
|
| 35 |
-
output_dir: str = "./corpus/"
|
| 36 |
-
split: str = "train"
|
| 37 |
-
max_samples: int = 100
|
| 38 |
|
| 39 |
|
| 40 |
class CompetitorConfig(BaseModel):
|
| 41 |
-
name: str = ""
|
| 42 |
-
ocr_engine: str = ""
|
| 43 |
-
"""Moteur OCR : ``tesseract``, ``mistral_ocr``, … ou ``corpus``
|
| 44 |
-
utiliser l'OCR pré-calculé."""
|
| 45 |
-
ocr_model: str = ""
|
| 46 |
-
llm_provider: str = ""
|
| 47 |
-
llm_model: str = ""
|
| 48 |
-
pipeline_mode: str = ""
|
| 49 |
-
prompt_file: str = ""
|
| 50 |
|
| 51 |
|
| 52 |
class BenchmarkRunRequest(BaseModel):
|
| 53 |
-
corpus_path: str
|
| 54 |
-
competitors: list[CompetitorConfig]
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
| 60 |
|
| 61 |
|
| 62 |
__all__ = [
|
|
|
|
|
|
|
|
|
|
| 63 |
"BenchmarkRequest",
|
| 64 |
"HTRUnitedImportRequest",
|
| 65 |
"HuggingFaceImportRequest",
|
|
|
|
| 1 |
"""Modèles Pydantic partagés par les routers FastAPI.
|
| 2 |
|
| 3 |
+
Ces schémas décrivent les payloads des requêtes ``POST`` consommées
|
| 4 |
+
par plusieurs endpoints du serveur web. Les sortir d'``app.py``
|
| 5 |
+
permet à chaque routeur de les importer sans dépendance vers
|
| 6 |
+
l'application elle-même.
|
| 7 |
+
|
| 8 |
+
Validation stricte
|
| 9 |
+
------------------
|
| 10 |
+
Tous les champs ``str`` ont une borne ``max_length`` proportionnée
|
| 11 |
+
à leur usage attendu (chemin filesystem, identifiant HuggingFace,
|
| 12 |
+
nom de rapport…) pour empêcher qu'un payload géant n'épuise la
|
| 13 |
+
mémoire avant validation. Les énumérations finies (langue OCR,
|
| 14 |
+
langue de rapport) sont typées en ``Literal[...]`` pour rejeter au
|
| 15 |
+
plus tôt les valeurs invalides.
|
| 16 |
"""
|
| 17 |
|
| 18 |
from __future__ import annotations
|
| 19 |
|
| 20 |
+
from typing import Literal
|
| 21 |
+
|
| 22 |
+
from pydantic import BaseModel, Field
|
| 23 |
+
|
| 24 |
+
# Bornes éditoriales — ajustées au plus large raisonnable, pas plus.
|
| 25 |
+
_MAX_PATH = 1024
|
| 26 |
+
"""Longueur max d'un chemin filesystem (limite POSIX généralement 4096)."""
|
| 27 |
+
|
| 28 |
+
_MAX_NAME = 256
|
| 29 |
+
"""Longueur max d'un identifiant ou nom court (rapport, label, dataset)."""
|
| 30 |
+
|
| 31 |
+
_MAX_PROMPT_FILENAME = 256
|
| 32 |
+
"""Nom de fichier prompt — ``"correction_medieval_french.txt"`` etc."""
|
| 33 |
+
|
| 34 |
+
_MAX_CHAR_EXCLUDE = 256
|
| 35 |
+
"""Liste de caractères à exclure (séparés par virgules)."""
|
| 36 |
+
|
| 37 |
+
_MAX_ENGINE_LIST = 32
|
| 38 |
+
"""Nombre max de moteurs OCR par requête legacy."""
|
| 39 |
+
|
| 40 |
+
_MAX_COMPETITORS = 32
|
| 41 |
+
"""Nombre max de concurrents composés par benchmark/run."""
|
| 42 |
+
|
| 43 |
+
# Codes ISO Tesseract acceptés pour le paramètre ``lang`` de
|
| 44 |
+
# ``BenchmarkRequest``. Liste explicite plutôt que ``str`` ouvert
|
| 45 |
+
# pour rejeter au plus tôt une valeur fantaisiste qui transiterait
|
| 46 |
+
# vers ``pytesseract`` en pure perte.
|
| 47 |
+
TesseractLang = Literal[
|
| 48 |
+
"fra", "lat", "eng", "deu", "ita", "spa", "por", "nld", "cat",
|
| 49 |
+
"rum", "ell", "ara", "heb", "rus", "ukr", "pol", "ces", "swe",
|
| 50 |
+
]
|
| 51 |
+
|
| 52 |
+
ReportLang = Literal["fr", "en"]
|
| 53 |
+
"""Langue du rapport HTML."""
|
| 54 |
+
|
| 55 |
+
NormalizationProfileId = Literal[
|
| 56 |
+
"nfc", "caseless", "minimal",
|
| 57 |
+
"medieval_french", "early_modern_french",
|
| 58 |
+
"medieval_latin",
|
| 59 |
+
"early_modern_english", "medieval_english",
|
| 60 |
+
]
|
| 61 |
+
"""Identifiants des profils de normalisation Unicode disponibles."""
|
| 62 |
|
| 63 |
|
| 64 |
class BenchmarkRequest(BaseModel):
|
| 65 |
+
corpus_path: str = Field(min_length=1, max_length=_MAX_PATH)
|
| 66 |
+
engines: list[str] = Field(default=["tesseract"], max_length=_MAX_ENGINE_LIST)
|
| 67 |
+
normalization_profile: NormalizationProfileId = "nfc"
|
| 68 |
+
char_exclude: str = Field(default="", max_length=_MAX_CHAR_EXCLUDE)
|
| 69 |
"""Caractères à ignorer (séparés par virgule, ex: ``"',–"``)."""
|
| 70 |
+
output_dir: str = Field(default="./rapports/", max_length=_MAX_PATH)
|
| 71 |
+
report_name: str = Field(default="", max_length=_MAX_NAME)
|
| 72 |
+
lang: TesseractLang = "fra"
|
| 73 |
+
report_lang: ReportLang = "fr"
|
| 74 |
"""Langue du rapport HTML : ``fr`` ou ``en``."""
|
| 75 |
|
| 76 |
|
| 77 |
class HTRUnitedImportRequest(BaseModel):
|
| 78 |
+
entry_id: str = Field(min_length=1, max_length=_MAX_NAME)
|
| 79 |
+
output_dir: str = Field(default="./corpus/", max_length=_MAX_PATH)
|
| 80 |
+
max_samples: int = Field(default=100, ge=1, le=10_000)
|
| 81 |
|
| 82 |
|
| 83 |
class HuggingFaceImportRequest(BaseModel):
|
| 84 |
+
dataset_id: str = Field(min_length=1, max_length=_MAX_NAME)
|
| 85 |
+
output_dir: str = Field(default="./corpus/", max_length=_MAX_PATH)
|
| 86 |
+
split: str = Field(default="train", max_length=_MAX_NAME)
|
| 87 |
+
max_samples: int = Field(default=100, ge=1, le=10_000)
|
| 88 |
|
| 89 |
|
| 90 |
class CompetitorConfig(BaseModel):
|
| 91 |
+
name: str = Field(default="", max_length=_MAX_NAME)
|
| 92 |
+
ocr_engine: str = Field(default="", max_length=_MAX_NAME)
|
| 93 |
+
"""Moteur OCR : ``tesseract``, ``mistral_ocr``, … ou ``corpus``
|
| 94 |
+
pour utiliser l'OCR pré-calculé."""
|
| 95 |
+
ocr_model: str = Field(default="", max_length=_MAX_NAME)
|
| 96 |
+
llm_provider: str = Field(default="", max_length=_MAX_NAME)
|
| 97 |
+
llm_model: str = Field(default="", max_length=_MAX_NAME)
|
| 98 |
+
pipeline_mode: str = Field(default="", max_length=_MAX_NAME)
|
| 99 |
+
prompt_file: str = Field(default="", max_length=_MAX_PROMPT_FILENAME)
|
| 100 |
|
| 101 |
|
| 102 |
class BenchmarkRunRequest(BaseModel):
|
| 103 |
+
corpus_path: str = Field(min_length=1, max_length=_MAX_PATH)
|
| 104 |
+
competitors: list[CompetitorConfig] = Field(
|
| 105 |
+
min_length=1, max_length=_MAX_COMPETITORS,
|
| 106 |
+
)
|
| 107 |
+
normalization_profile: NormalizationProfileId = "nfc"
|
| 108 |
+
char_exclude: str = Field(default="", max_length=_MAX_CHAR_EXCLUDE)
|
| 109 |
+
output_dir: str = Field(default="./rapports/", max_length=_MAX_PATH)
|
| 110 |
+
report_name: str = Field(default="", max_length=_MAX_NAME)
|
| 111 |
+
report_lang: ReportLang = "fr"
|
| 112 |
|
| 113 |
|
| 114 |
__all__ = [
|
| 115 |
+
"TesseractLang",
|
| 116 |
+
"ReportLang",
|
| 117 |
+
"NormalizationProfileId",
|
| 118 |
"BenchmarkRequest",
|
| 119 |
"HTRUnitedImportRequest",
|
| 120 |
"HuggingFaceImportRequest",
|
|
@@ -110,11 +110,10 @@ async def api_benchmark_run(req: BenchmarkRunRequest, request: Request) -> dict:
|
|
| 110 |
raise HTTPException(
|
| 111 |
status_code=400, detail=f"Corpus non trouvé : {req.corpus_path}",
|
| 112 |
)
|
| 113 |
-
|
| 114 |
-
raise HTTPException(status_code=400, detail="Aucun concurrent défini.")
|
| 115 |
|
| 116 |
-
#
|
| 117 |
-
#
|
| 118 |
try:
|
| 119 |
for comp in req.competitors:
|
| 120 |
assert_engines_allowed([comp.ocr_engine] if comp.ocr_engine else [])
|
|
|
|
| 110 |
raise HTTPException(
|
| 111 |
status_code=400, detail=f"Corpus non trouvé : {req.corpus_path}",
|
| 112 |
)
|
| 113 |
+
# ``competitors`` non vide est garanti par Pydantic ``min_length=1``.
|
|
|
|
| 114 |
|
| 115 |
+
# Mode public : refuse les pipelines LLM mutualisés et les moteurs
|
| 116 |
+
# OCR cloud sollicités par n'importe quel concurrent.
|
| 117 |
try:
|
| 118 |
for comp in req.competitors:
|
| 119 |
assert_engines_allowed([comp.ocr_engine] if comp.ocr_engine else [])
|
|
@@ -1127,7 +1127,9 @@ class TestFastAPIBenchmarkRun:
|
|
| 1127 |
"corpus_path": str(tmp_corpus),
|
| 1128 |
"competitors": [],
|
| 1129 |
})
|
| 1130 |
-
|
|
|
|
|
|
|
| 1131 |
|
| 1132 |
def test_run_missing_ocr_engine_accepted(self, client, tmp_corpus):
|
| 1133 |
"""ocr_engine est désormais optionnel (vide = post-correction corpus)."""
|
|
|
|
| 1127 |
"corpus_path": str(tmp_corpus),
|
| 1128 |
"competitors": [],
|
| 1129 |
})
|
| 1130 |
+
# Pydantic ``min_length=1`` rejette en 422 Unprocessable Entity
|
| 1131 |
+
# (code HTTP standard pour payload invalide).
|
| 1132 |
+
assert r.status_code == 422
|
| 1133 |
|
| 1134 |
def test_run_missing_ocr_engine_accepted(self, client, tmp_corpus):
|
| 1135 |
"""ocr_engine est désormais optionnel (vide = post-correction corpus)."""
|