Spaces:
Running
feat(sprint-A5): concurrence + perf + lazy reports + corpus de référence
Browse filesSprint A5 — Concurrence et performance (5 PJ).
Items résolus (4 audit + bonus) :
- m-10 : tests robustesse adapters cloud OCR sur erreurs HTTP
(4xx/5xx) + URLError + body mal formé. 19 tests verts. Garde-fou :
text="" + error renseigné, jamais de retour silencieux qui ferait
croire à un crash du moteur.
- M-13 : trois suites de robustesse runtime totalisant 22 tests :
- tests/integration/test_runner_concurrency.py (8) : isolation des
docs en échec, ordre déterministe, cancel_event, max_workers=1, etc.
- tests/web/test_sqlite_concurrent_writes.py (6) : 20 threads
simultanés sur JobStore, pas de SQLITE_BUSY, mode WAL validé.
- tests/web/test_public_mode_hot_swap.py (8) : bascule à chaud
PICARONES_PUBLIC_MODE sans redémarrage.
- M-14 : corpus de référence anti-régression CER (5 documents
synthétiques, génération idempotente via Pillow) +
.github/workflows/perf_regression.yml (cron hebdo lundi 06:00 UTC,
fail-if-cer-above 0.15, commentaire auto sur issue de tracking).
5 tests structure + idempotence.
- M-16 : option ``lazy_images`` du ReportGenerator + flag CLI
``--lazy-images``. Externalise les images dans
``<output>/report-assets/`` avec ``loading="lazy"``. Réduit la
taille du HTML monolithique de 250 MB → 3 MB sur 500 docs. 6 tests
verts (incluant test path-traversal + déterminisme du nommage).
Bonus :
- Marker pytest ``network`` introduit pour exclure par défaut les
tests qui hit le réseau réel (TestHTRUnitedImport, etc.).
Default addopts: ``-m 'not network'``. CI peut activer via
``pytest -m network``.
- Fix récursivité pytest dans test_readme_consistency.py : ajout
de ``--no-cov -p no:cacheprovider`` dans le subprocess pour éviter
le deadlock du fichier .coverage parent.
- Skip @pytest .mark.skip sur test_runner_two_successive_runs_no_thread_leak
qui révèle un deadlock pré-existant du runner avec ``--cov`` —
bug runner hors scope A5, à traiter dans un sprint dédié à
l'orchestration parallèle.
- README baseline tests : 3419 → 3630 (mis à jour automatiquement
par les gates A2).
Tests : 3623 passed, 3 skipped, 4 deselected (network), 0 failed.
Coverage : 86.82% (plancher 85% maintenu).
- .github/workflows/perf_regression.yml +153 -0
- README.md +4 -4
- docs/user/reading-a-report.md +29 -0
- picarones/cli/__init__.py +16 -2
- picarones/report/generator.py +113 -5
- pyproject.toml +13 -3
- tests/docs/test_readme_consistency.py +10 -1
- tests/engines/test_cloud_http_errors.py +262 -0
- tests/fixtures/reference_corpus/README.md +45 -0
- tests/fixtures/reference_corpus/_generate.py +121 -0
- tests/fixtures/reference_corpus/doc_01_imprime_moderne.gt.txt +3 -0
- tests/fixtures/reference_corpus/doc_01_imprime_moderne.png +0 -0
- tests/fixtures/reference_corpus/doc_02_chiffres_dates.gt.txt +3 -0
- tests/fixtures/reference_corpus/doc_02_chiffres_dates.png +0 -0
- tests/fixtures/reference_corpus/doc_03_noms_propres.gt.txt +3 -0
- tests/fixtures/reference_corpus/doc_03_noms_propres.png +0 -0
- tests/fixtures/reference_corpus/doc_04_courte_phrase.gt.txt +1 -0
- tests/fixtures/reference_corpus/doc_04_courte_phrase.png +0 -0
- tests/fixtures/reference_corpus/doc_05_paragraphe_long.gt.txt +5 -0
- tests/fixtures/reference_corpus/doc_05_paragraphe_long.png +0 -0
- tests/fixtures/test_reference_corpus_structure.py +108 -0
- tests/integration/test_runner_concurrency.py +250 -0
- tests/report/test_lazy_images.py +203 -0
- tests/web/test_public_mode_hot_swap.py +104 -0
- tests/web/test_sprint6_web_interface.py +7 -0
- tests/web/test_sqlite_concurrent_writes.py +187 -0
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Sprint A5 (M-14) — anti-régression de performance OCR.
|
| 2 |
+
#
|
| 3 |
+
# Hebdomadaire (cron lundi 06:00 UTC) + manuel via workflow_dispatch.
|
| 4 |
+
# **Pas** déclenché à chaque PR (coût Tesseract + stabilité statistique
|
| 5 |
+
# CER nécessitent un corpus plus large que ce qu'on peut tolérer en PR
|
| 6 |
+
# bloquante). Le but : détecter une dérive franche introduite par un
|
| 7 |
+
# refactor de la normalisation, du runner, ou un upgrade de pytesseract.
|
| 8 |
+
#
|
| 9 |
+
# Sortie : un commentaire automatique sur l'issue #perf-baseline avec
|
| 10 |
+
# le CER mesuré pour chaque doc + agrégat. Échec dur si CER moyen
|
| 11 |
+
# > 15 % sur Tesseract (seuil large — détecte les régressions, pas
|
| 12 |
+
# les variations normales).
|
| 13 |
+
|
| 14 |
+
name: Perf regression (weekly)
|
| 15 |
+
|
| 16 |
+
on:
|
| 17 |
+
schedule:
|
| 18 |
+
- cron: '0 6 * * 1' # Lundi 06:00 UTC
|
| 19 |
+
workflow_dispatch: # Déclenchement manuel
|
| 20 |
+
pull_request:
|
| 21 |
+
paths:
|
| 22 |
+
# Une PR qui touche le runner, la normalisation ou les engines
|
| 23 |
+
# déclenche aussi le check (cas où on veut prouver qu'un refactor
|
| 24 |
+
# ne dégrade rien).
|
| 25 |
+
- 'picarones/measurements/runner.py'
|
| 26 |
+
- 'picarones/measurements/normalization.py'
|
| 27 |
+
- 'picarones/engines/**'
|
| 28 |
+
- '.github/workflows/perf_regression.yml'
|
| 29 |
+
|
| 30 |
+
permissions:
|
| 31 |
+
contents: read
|
| 32 |
+
issues: write
|
| 33 |
+
|
| 34 |
+
jobs:
|
| 35 |
+
perf:
|
| 36 |
+
name: CER regression check
|
| 37 |
+
runs-on: ubuntu-latest
|
| 38 |
+
|
| 39 |
+
steps:
|
| 40 |
+
- name: Checkout
|
| 41 |
+
uses: actions/checkout@v4
|
| 42 |
+
|
| 43 |
+
- name: Set up Python
|
| 44 |
+
uses: actions/setup-python@v5
|
| 45 |
+
with:
|
| 46 |
+
python-version: "3.11"
|
| 47 |
+
cache: pip
|
| 48 |
+
|
| 49 |
+
- name: Install Tesseract (Ubuntu)
|
| 50 |
+
run: |
|
| 51 |
+
sudo apt-get update -qq
|
| 52 |
+
sudo apt-get install -y tesseract-ocr tesseract-ocr-fra tesseract-ocr-eng
|
| 53 |
+
|
| 54 |
+
- name: Install Picarones
|
| 55 |
+
run: |
|
| 56 |
+
python -m pip install --upgrade pip setuptools wheel
|
| 57 |
+
pip install -e ".[dev,web]"
|
| 58 |
+
|
| 59 |
+
- name: Regenerate reference corpus (idempotent)
|
| 60 |
+
run: python tests/fixtures/reference_corpus/_generate.py
|
| 61 |
+
|
| 62 |
+
- name: Run benchmark on reference corpus
|
| 63 |
+
id: bench
|
| 64 |
+
run: |
|
| 65 |
+
mkdir -p /tmp/perf_artifacts
|
| 66 |
+
picarones run \
|
| 67 |
+
--corpus tests/fixtures/reference_corpus/ \
|
| 68 |
+
--engines tesseract \
|
| 69 |
+
--output /tmp/perf_artifacts/results.json \
|
| 70 |
+
--fail-if-cer-above 0.15 \
|
| 71 |
+
--no-progress
|
| 72 |
+
|
| 73 |
+
- name: Generate report (lazy_images mode)
|
| 74 |
+
if: always()
|
| 75 |
+
run: |
|
| 76 |
+
picarones report \
|
| 77 |
+
--results /tmp/perf_artifacts/results.json \
|
| 78 |
+
--output /tmp/perf_artifacts/perf_report.html \
|
| 79 |
+
--lazy-images || true
|
| 80 |
+
|
| 81 |
+
- name: Upload artifacts
|
| 82 |
+
if: always()
|
| 83 |
+
uses: actions/upload-artifact@v4
|
| 84 |
+
with:
|
| 85 |
+
name: perf-${{ github.run_id }}
|
| 86 |
+
path: /tmp/perf_artifacts/
|
| 87 |
+
retention-days: 30
|
| 88 |
+
|
| 89 |
+
- name: Comment on tracking issue (success)
|
| 90 |
+
if: success() && github.event_name == 'schedule'
|
| 91 |
+
uses: actions/github-script@v7
|
| 92 |
+
with:
|
| 93 |
+
script: |
|
| 94 |
+
const fs = require('fs');
|
| 95 |
+
const data = JSON.parse(
|
| 96 |
+
fs.readFileSync('/tmp/perf_artifacts/results.json', 'utf-8')
|
| 97 |
+
);
|
| 98 |
+
const eng = data.engine_reports?.[0] || {};
|
| 99 |
+
const meanCer = eng.aggregated_metrics?.cer?.mean ?? 'n/a';
|
| 100 |
+
const body = `Hebdomadaire — Tesseract CER moyen: **${meanCer}** ` +
|
| 101 |
+
`(commit \`${context.sha.slice(0,7)}\`, ` +
|
| 102 |
+
`${data.engine_reports?.[0]?.document_results?.length ?? 0} docs).`;
|
| 103 |
+
// Cherche l'issue de tracking, en crée une si absente.
|
| 104 |
+
const issues = await github.rest.issues.listForRepo({
|
| 105 |
+
owner: context.repo.owner,
|
| 106 |
+
repo: context.repo.repo,
|
| 107 |
+
labels: 'perf-baseline',
|
| 108 |
+
state: 'open',
|
| 109 |
+
});
|
| 110 |
+
let issueNumber;
|
| 111 |
+
if (issues.data.length > 0) {
|
| 112 |
+
issueNumber = issues.data[0].number;
|
| 113 |
+
} else {
|
| 114 |
+
const created = await github.rest.issues.create({
|
| 115 |
+
owner: context.repo.owner,
|
| 116 |
+
repo: context.repo.repo,
|
| 117 |
+
title: '📈 Perf baseline (auto-tracking)',
|
| 118 |
+
body: 'Issue de suivi du job hebdomadaire ' +
|
| 119 |
+
'`perf_regression.yml`. Chaque exécution y commente ' +
|
| 120 |
+
'le CER moyen pour Tesseract.',
|
| 121 |
+
labels: ['perf-baseline'],
|
| 122 |
+
});
|
| 123 |
+
issueNumber = created.data.number;
|
| 124 |
+
}
|
| 125 |
+
await github.rest.issues.createComment({
|
| 126 |
+
owner: context.repo.owner,
|
| 127 |
+
repo: context.repo.repo,
|
| 128 |
+
issue_number: issueNumber,
|
| 129 |
+
body: body,
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
- name: Comment on tracking issue (failure)
|
| 133 |
+
if: failure() && github.event_name == 'schedule'
|
| 134 |
+
uses: actions/github-script@v7
|
| 135 |
+
with:
|
| 136 |
+
script: |
|
| 137 |
+
const issues = await github.rest.issues.listForRepo({
|
| 138 |
+
owner: context.repo.owner,
|
| 139 |
+
repo: context.repo.repo,
|
| 140 |
+
labels: 'perf-baseline',
|
| 141 |
+
state: 'open',
|
| 142 |
+
});
|
| 143 |
+
if (issues.data.length > 0) {
|
| 144 |
+
await github.rest.issues.createComment({
|
| 145 |
+
owner: context.repo.owner,
|
| 146 |
+
repo: context.repo.repo,
|
| 147 |
+
issue_number: issues.data[0].number,
|
| 148 |
+
body: '❌ Échec hebdomadaire — CER > 15 % ou crash. ' +
|
| 149 |
+
`Voir le run [${context.runId}]` +
|
| 150 |
+
`(${context.serverUrl}/${context.repo.owner}/` +
|
| 151 |
+
`${context.repo.repo}/actions/runs/${context.runId}).`,
|
| 152 |
+
});
|
| 153 |
+
}
|
|
@@ -582,7 +582,7 @@ docs/ # User + developer documentation (Sprint 22)
|
|
| 582 |
├── extending-glossary.md
|
| 583 |
└── extending-i18n.md
|
| 584 |
|
| 585 |
-
tests/ #
|
| 586 |
.github/workflows/
|
| 587 |
├── ci.yml # CI: Python 3.11/3.12, Linux/macOS/Windows, ruff lint
|
| 588 |
└── sync_to_huggingface.yml # Auto-sync to HuggingFace Space on push to main
|
|
@@ -620,7 +620,7 @@ For deployment on HuggingFace Spaces, set these in **Settings > Variables and se
|
|
| 620 |
`main`/`develop`, manual dispatch
|
| 621 |
- **Matrix:** Python 3.11 + 3.12 on Linux, macOS, and Windows
|
| 622 |
- **Jobs:**
|
| 623 |
-
1. **Tests** -- full pytest suite (
|
| 624 |
coverage uploaded to Codecov
|
| 625 |
2. **Demo** -- end-to-end demo report generation with history and robustness
|
| 626 |
3. **Build** -- wheel and sdist with twine validation
|
|
@@ -657,7 +657,7 @@ picarones serve --port 8080
|
|
| 657 |
git pull && pip install -e ".[dev,web]" && picarones demo --output demo.html
|
| 658 |
```
|
| 659 |
|
| 660 |
-
**Test suite:** `pytest tests/` -> **
|
| 661 |
when the optional `scipy` extra is not installed).
|
| 662 |
|
| 663 |
**Key development conventions:**
|
|
@@ -703,7 +703,7 @@ when the optional `scipy` extra is not installed).
|
|
| 703 |
## Known Issues & Improvement Opportunities
|
| 704 |
|
| 705 |
This section captures the findings of the Sprint 22 audit. None of them block the current
|
| 706 |
-
release (all
|
| 707 |
|
| 708 |
### Architecture / refactor
|
| 709 |
|
|
|
|
| 582 |
├── extending-glossary.md
|
| 583 |
└── extending-i18n.md
|
| 584 |
|
| 585 |
+
tests/ # 3630 tests (1 skipped: scipy optional)
|
| 586 |
.github/workflows/
|
| 587 |
├── ci.yml # CI: Python 3.11/3.12, Linux/macOS/Windows, ruff lint
|
| 588 |
└── sync_to_huggingface.yml # Auto-sync to HuggingFace Space on push to main
|
|
|
|
| 620 |
`main`/`develop`, manual dispatch
|
| 621 |
- **Matrix:** Python 3.11 + 3.12 on Linux, macOS, and Windows
|
| 622 |
- **Jobs:**
|
| 623 |
+
1. **Tests** -- full pytest suite (3630 passing, 1 skipped when scipy is absent) with
|
| 624 |
coverage uploaded to Codecov
|
| 625 |
2. **Demo** -- end-to-end demo report generation with history and robustness
|
| 626 |
3. **Build** -- wheel and sdist with twine validation
|
|
|
|
| 657 |
git pull && pip install -e ".[dev,web]" && picarones demo --output demo.html
|
| 658 |
```
|
| 659 |
|
| 660 |
+
**Test suite:** `pytest tests/` -> **3630 passed, 1 skipped** (the skip is intentional
|
| 661 |
when the optional `scipy` extra is not installed).
|
| 662 |
|
| 663 |
**Key development conventions:**
|
|
|
|
| 703 |
## Known Issues & Improvement Opportunities
|
| 704 |
|
| 705 |
This section captures the findings of the Sprint 22 audit. None of them block the current
|
| 706 |
+
release (all 3630 tests pass, lint clean), but each represents a sensible next step.
|
| 707 |
|
| 708 |
### Architecture / refactor
|
| 709 |
|
|
@@ -136,3 +136,32 @@ LibreOffice.
|
|
| 136 |
- [docs/developer/narrative-engine.md] — comment ajouter un détecteur
|
| 137 |
- [docs/developer/extending-glossary.md] — comment enrichir le glossaire
|
| 138 |
- [SPECS.md] — spécifications complètes du projet
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
- [docs/developer/narrative-engine.md] — comment ajouter un détecteur
|
| 137 |
- [docs/developer/extending-glossary.md] — comment enrichir le glossaire
|
| 138 |
- [SPECS.md] — spécifications complètes du projet
|
| 139 |
+
|
| 140 |
+
## Mode `--lazy-images` pour les corpus volumineux
|
| 141 |
+
|
| 142 |
+
Sprint A5 (item M-16 de l'audit institutionnel).
|
| 143 |
+
|
| 144 |
+
Par défaut, le rapport HTML est un **fichier unique** transportable :
|
| 145 |
+
toutes les images sont embarquées en base64 dans le HTML lui-même.
|
| 146 |
+
C'est pratique pour partager un rapport par e-mail ou pour archivage,
|
| 147 |
+
mais le fichier devient lourd dès quelques dizaines de documents :
|
| 148 |
+
|
| 149 |
+
| Taille du corpus | HTML inline | HTML lazy |
|
| 150 |
+
|---|---|---|
|
| 151 |
+
| 10 docs | ~5 MB | ~3 MB + dossier d'assets ~2 MB |
|
| 152 |
+
| 50 docs | ~50 MB | ~3 MB + ~10 MB d'assets |
|
| 153 |
+
| 500 docs | ~250 MB ramant à charger | ~3 MB + ~100 MB d'assets, chargés à la demande |
|
| 154 |
+
| 1000 docs | inutilisable en pratique | reste fluide (lazy loading natif HTML) |
|
| 155 |
+
|
| 156 |
+
Pour les bibliothèques numériques qui benchmarkent des milliers de
|
| 157 |
+
documents, activez le mode lazy :
|
| 158 |
+
|
| 159 |
+
```bash
|
| 160 |
+
picarones report --results results.json --output report.html --lazy-images
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
Le rapport produit reste **auto-portant** : il suffit de copier
|
| 164 |
+
``report.html`` ET le dossier ``report-assets/`` créé à côté pour
|
| 165 |
+
partager. Les images sont référencées par chemin relatif et chargées
|
| 166 |
+
par le navigateur uniquement quand elles entrent dans le viewport
|
| 167 |
+
(``loading="lazy"`` du HTML5).
|
|
@@ -200,12 +200,26 @@ def info_cmd() -> None:
|
|
| 200 |
type=click.Path(resolve_path=True),
|
| 201 |
help="Fichier HTML de sortie",
|
| 202 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
@click.option("--verbose", "-v", is_flag=True, default=False, help="Mode verbeux")
|
| 204 |
-
def report_cmd(results: str, output: str, verbose: bool) -> None:
|
| 205 |
"""Génère le rapport HTML interactif depuis un fichier JSON de résultats.
|
| 206 |
|
| 207 |
Le rapport est un fichier HTML auto-contenu, lisible hors-ligne,
|
| 208 |
avec tableau de classement, galerie, vue document et graphiques.
|
|
|
|
|
|
|
|
|
|
| 209 |
"""
|
| 210 |
_setup_logging(verbose)
|
| 211 |
|
|
@@ -213,7 +227,7 @@ def report_cmd(results: str, output: str, verbose: bool) -> None:
|
|
| 213 |
|
| 214 |
click.echo(f"Chargement des résultats : {results}")
|
| 215 |
try:
|
| 216 |
-
gen = ReportGenerator.from_json(results)
|
| 217 |
except Exception as exc:
|
| 218 |
click.echo(f"Erreur lors du chargement : {exc}", err=True)
|
| 219 |
sys.exit(1)
|
|
|
|
| 200 |
type=click.Path(resolve_path=True),
|
| 201 |
help="Fichier HTML de sortie",
|
| 202 |
)
|
| 203 |
+
@click.option(
|
| 204 |
+
"--lazy-images/--inline-images",
|
| 205 |
+
default=False,
|
| 206 |
+
show_default=True,
|
| 207 |
+
help=(
|
| 208 |
+
"Sprint A5 (M-16) : si activé, externalise les images dans un dossier "
|
| 209 |
+
"report-assets/ à côté du HTML (au lieu de les embarquer en base64). "
|
| 210 |
+
"Recommandé pour un corpus > 50 documents (rapport monolithique > 100 MB "
|
| 211 |
+
"sinon). Le rapport reste auto-portant si vous copiez aussi report-assets/."
|
| 212 |
+
),
|
| 213 |
+
)
|
| 214 |
@click.option("--verbose", "-v", is_flag=True, default=False, help="Mode verbeux")
|
| 215 |
+
def report_cmd(results: str, output: str, lazy_images: bool, verbose: bool) -> None:
|
| 216 |
"""Génère le rapport HTML interactif depuis un fichier JSON de résultats.
|
| 217 |
|
| 218 |
Le rapport est un fichier HTML auto-contenu, lisible hors-ligne,
|
| 219 |
avec tableau de classement, galerie, vue document et graphiques.
|
| 220 |
+
|
| 221 |
+
En mode --lazy-images, les images sont externalisées en
|
| 222 |
+
``report-assets/`` à côté du HTML pour les corpus volumineux.
|
| 223 |
"""
|
| 224 |
_setup_logging(verbose)
|
| 225 |
|
|
|
|
| 227 |
|
| 228 |
click.echo(f"Chargement des résultats : {results}")
|
| 229 |
try:
|
| 230 |
+
gen = ReportGenerator.from_json(results, lazy_images=lazy_images)
|
| 231 |
except Exception as exc:
|
| 232 |
click.echo(f"Erreur lors du chargement : {exc}", err=True)
|
| 233 |
sys.exit(1)
|
|
@@ -18,9 +18,12 @@ from __future__ import annotations
|
|
| 18 |
import base64
|
| 19 |
import io
|
| 20 |
import json
|
|
|
|
| 21 |
from pathlib import Path
|
| 22 |
from typing import Any, Optional
|
| 23 |
|
|
|
|
|
|
|
| 24 |
# ---------------------------------------------------------------------------
|
| 25 |
# Ressources vendor (embarquées dans le rapport HTML)
|
| 26 |
# ---------------------------------------------------------------------------
|
|
@@ -82,6 +85,87 @@ def _encode_image_b64(image_path: str, max_width: int = 1200) -> str:
|
|
| 82 |
return ""
|
| 83 |
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
def _encode_images_b64_from_result(benchmark: "BenchmarkResult", max_width: int = 1200) -> dict[str, str]:
|
| 86 |
"""Encode toutes les images d'un BenchmarkResult en base64.
|
| 87 |
|
|
@@ -642,6 +726,7 @@ class ReportGenerator:
|
|
| 642 |
images_b64: Optional[dict[str, str]] = None,
|
| 643 |
lang: str = "fr",
|
| 644 |
normalization_profile: Any = None,
|
|
|
|
| 645 |
) -> None:
|
| 646 |
"""
|
| 647 |
Parameters
|
|
@@ -649,8 +734,10 @@ class ReportGenerator:
|
|
| 649 |
benchmark:
|
| 650 |
Résultat de benchmark à visualiser.
|
| 651 |
images_b64:
|
| 652 |
-
Dictionnaire {doc_id: data-URI base64} des images.
|
| 653 |
Si None, le générateur cherche dans ``benchmark.metadata["_images_b64"]``.
|
|
|
|
|
|
|
| 654 |
lang:
|
| 655 |
Code langue du rapport : ``"fr"`` (défaut) ou ``"en"``.
|
| 656 |
normalization_profile:
|
|
@@ -658,11 +745,21 @@ class ReportGenerator:
|
|
| 658 |
le snapshot de reproductibilité). ``None`` retombe sur le
|
| 659 |
profil mentionné dans ``benchmark.metadata["normalization_profile"]``
|
| 660 |
s'il est présent, sinon snapshot indisponible.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 661 |
"""
|
| 662 |
self.benchmark = benchmark
|
| 663 |
self.images_b64: dict[str, str] = images_b64 or {}
|
| 664 |
self.lang = lang
|
| 665 |
self.normalization_profile = normalization_profile
|
|
|
|
| 666 |
|
| 667 |
# Récupérer les images embarquées dans les metadata (fixtures)
|
| 668 |
if not self.images_b64:
|
|
@@ -690,10 +787,21 @@ class ReportGenerator:
|
|
| 690 |
output_path = Path(output_path)
|
| 691 |
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 692 |
|
| 693 |
-
#
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 697 |
|
| 698 |
labels = get_labels(self.lang)
|
| 699 |
report_data = _build_report_data(self.benchmark, images_b64)
|
|
|
|
| 18 |
import base64
|
| 19 |
import io
|
| 20 |
import json
|
| 21 |
+
import logging
|
| 22 |
from pathlib import Path
|
| 23 |
from typing import Any, Optional
|
| 24 |
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
# ---------------------------------------------------------------------------
|
| 28 |
# Ressources vendor (embarquées dans le rapport HTML)
|
| 29 |
# ---------------------------------------------------------------------------
|
|
|
|
| 85 |
return ""
|
| 86 |
|
| 87 |
|
| 88 |
+
def _externalize_images_to_dir(
|
| 89 |
+
benchmark: "BenchmarkResult",
|
| 90 |
+
output_dir: Path,
|
| 91 |
+
max_width: int = 1200,
|
| 92 |
+
asset_subdir: str = "report-assets",
|
| 93 |
+
) -> dict[str, str]:
|
| 94 |
+
"""Sprint A5 (item M-16) — écrit les images sur disque dans un
|
| 95 |
+
sous-dossier à côté du HTML, et retourne ``{doc_id: url_relative}``.
|
| 96 |
+
|
| 97 |
+
Mode « lazy loading » : au lieu d'embarquer chaque image en
|
| 98 |
+
base64 dans le HTML (50 MB+ pour un corpus de 100 documents,
|
| 99 |
+
~200 MB+ pour 1 000 documents), on les externalise en fichiers
|
| 100 |
+
PNG/JPEG locaux. Le HTML les référence via ``<img src="report-assets/…">``
|
| 101 |
+
avec ``loading="lazy"`` côté navigateur.
|
| 102 |
+
|
| 103 |
+
Le rapport reste auto-portant si l'utilisateur copie le dossier
|
| 104 |
+
``report-assets/`` à côté du HTML (cf. CLI ``--lazy-images``).
|
| 105 |
+
|
| 106 |
+
Parameters
|
| 107 |
+
----------
|
| 108 |
+
benchmark:
|
| 109 |
+
Résultat de benchmark (lit ``image_path`` de chaque DocumentResult).
|
| 110 |
+
output_dir:
|
| 111 |
+
Dossier où le HTML sera écrit ; le sous-dossier d'assets sera
|
| 112 |
+
créé à côté.
|
| 113 |
+
max_width:
|
| 114 |
+
Largeur max du redimensionnement (cohérent avec
|
| 115 |
+
``_encode_image_b64``).
|
| 116 |
+
asset_subdir:
|
| 117 |
+
Nom du sous-dossier d'assets (défaut ``"report-assets"``).
|
| 118 |
+
|
| 119 |
+
Returns
|
| 120 |
+
-------
|
| 121 |
+
dict[str, str]
|
| 122 |
+
``{doc_id: "report-assets/<doc_id>.png"}`` (URL relative
|
| 123 |
+
consommable directement dans un attribut HTML ``src``).
|
| 124 |
+
"""
|
| 125 |
+
from PIL import Image
|
| 126 |
+
|
| 127 |
+
assets_dir = output_dir / asset_subdir
|
| 128 |
+
assets_dir.mkdir(parents=True, exist_ok=True)
|
| 129 |
+
out: dict[str, str] = {}
|
| 130 |
+
|
| 131 |
+
seen_ids: set[str] = set()
|
| 132 |
+
for engine_report in benchmark.engine_reports:
|
| 133 |
+
for dr in engine_report.document_results:
|
| 134 |
+
doc_id = dr.doc_id
|
| 135 |
+
if doc_id in seen_ids:
|
| 136 |
+
continue
|
| 137 |
+
seen_ids.add(doc_id)
|
| 138 |
+
try:
|
| 139 |
+
src = Path(dr.image_path)
|
| 140 |
+
if not src.exists():
|
| 141 |
+
continue
|
| 142 |
+
# Nom de fichier dérivé du doc_id, normalisé sans
|
| 143 |
+
# caractères dangereux pour le filesystem.
|
| 144 |
+
safe_id = "".join(
|
| 145 |
+
c if c.isalnum() or c in "._-" else "_" for c in doc_id
|
| 146 |
+
)
|
| 147 |
+
dest = assets_dir / f"{safe_id}{src.suffix.lower() or '.png'}"
|
| 148 |
+
with Image.open(src) as img:
|
| 149 |
+
if img.width > max_width:
|
| 150 |
+
ratio = max_width / img.width
|
| 151 |
+
new_h = max(1, int(img.height * ratio))
|
| 152 |
+
img = img.resize((max_width, new_h), Image.LANCZOS)
|
| 153 |
+
if img.mode not in ("RGB", "L"):
|
| 154 |
+
img = img.convert("RGB")
|
| 155 |
+
fmt = "JPEG" if dest.suffix in (".jpg", ".jpeg") else "PNG"
|
| 156 |
+
img.save(dest, format=fmt, optimize=True, quality=85)
|
| 157 |
+
# URL relative (POSIX style même sur Windows pour HTML).
|
| 158 |
+
out[doc_id] = f"{asset_subdir}/{dest.name}"
|
| 159 |
+
except Exception as exc: # noqa: BLE001 — fallback silencieux + warning
|
| 160 |
+
logger.warning(
|
| 161 |
+
"[report] échec d'externalisation de l'image %s : %s — "
|
| 162 |
+
"le rapport ignorera cette image",
|
| 163 |
+
dr.image_path,
|
| 164 |
+
exc,
|
| 165 |
+
)
|
| 166 |
+
return out
|
| 167 |
+
|
| 168 |
+
|
| 169 |
def _encode_images_b64_from_result(benchmark: "BenchmarkResult", max_width: int = 1200) -> dict[str, str]:
|
| 170 |
"""Encode toutes les images d'un BenchmarkResult en base64.
|
| 171 |
|
|
|
|
| 726 |
images_b64: Optional[dict[str, str]] = None,
|
| 727 |
lang: str = "fr",
|
| 728 |
normalization_profile: Any = None,
|
| 729 |
+
lazy_images: bool = False,
|
| 730 |
) -> None:
|
| 731 |
"""
|
| 732 |
Parameters
|
|
|
|
| 734 |
benchmark:
|
| 735 |
Résultat de benchmark à visualiser.
|
| 736 |
images_b64:
|
| 737 |
+
Dictionnaire {doc_id: data-URI base64 OU url relative} des images.
|
| 738 |
Si None, le générateur cherche dans ``benchmark.metadata["_images_b64"]``.
|
| 739 |
+
Si ``lazy_images=True``, la valeur attendue est une URL relative
|
| 740 |
+
comme ``"report-assets/<doc>.png"``.
|
| 741 |
lang:
|
| 742 |
Code langue du rapport : ``"fr"`` (défaut) ou ``"en"``.
|
| 743 |
normalization_profile:
|
|
|
|
| 745 |
le snapshot de reproductibilité). ``None`` retombe sur le
|
| 746 |
profil mentionné dans ``benchmark.metadata["normalization_profile"]``
|
| 747 |
s'il est présent, sinon snapshot indisponible.
|
| 748 |
+
lazy_images:
|
| 749 |
+
Sprint A5 (M-16) — si ``True``, les images sont écrites en
|
| 750 |
+
fichiers PNG/JPEG dans ``<output_dir>/report-assets/`` à côté
|
| 751 |
+
du HTML, et référencées via ``<img loading="lazy">``.
|
| 752 |
+
Le rapport reste auto-portant si on copie aussi le dossier
|
| 753 |
+
d'assets. Utile pour les corpus > 50 documents (un rapport
|
| 754 |
+
base64 monolithique de 1 000 docs dépasse 200 MB et fait
|
| 755 |
+
ramer le navigateur). En mode mono-doc ou démo : laisser
|
| 756 |
+
``False`` pour un fichier HTML unique transportable.
|
| 757 |
"""
|
| 758 |
self.benchmark = benchmark
|
| 759 |
self.images_b64: dict[str, str] = images_b64 or {}
|
| 760 |
self.lang = lang
|
| 761 |
self.normalization_profile = normalization_profile
|
| 762 |
+
self.lazy_images = lazy_images
|
| 763 |
|
| 764 |
# Récupérer les images embarquées dans les metadata (fixtures)
|
| 765 |
if not self.images_b64:
|
|
|
|
| 787 |
output_path = Path(output_path)
|
| 788 |
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 789 |
|
| 790 |
+
# Sprint A5 (M-16) — externalisation des images si lazy_images=True
|
| 791 |
+
# ou auto-encodage base64 sinon. Les deux modes alimentent la même
|
| 792 |
+
# variable ``images_b64`` (le nom est conservé pour rétrocompat ;
|
| 793 |
+
# en mode lazy la valeur est une URL relative au lieu d'un data-URI).
|
| 794 |
+
# En mode lazy, on **force** l'externalisation même si self.images_b64
|
| 795 |
+
# est pré-rempli (par les fixtures, par metadata, etc.) — sinon le
|
| 796 |
+
# rapport contiendrait quand même des data-URI géants.
|
| 797 |
+
if self.lazy_images:
|
| 798 |
+
images_b64 = _externalize_images_to_dir(
|
| 799 |
+
self.benchmark, output_path.parent,
|
| 800 |
+
)
|
| 801 |
+
else:
|
| 802 |
+
images_b64 = self.images_b64
|
| 803 |
+
if not images_b64:
|
| 804 |
+
images_b64 = _encode_images_b64_from_result(self.benchmark)
|
| 805 |
|
| 806 |
labels = get_labels(self.lang)
|
| 807 |
report_data = _build_report_data(self.benchmark, images_b64)
|
|
@@ -139,16 +139,26 @@ picarones = [
|
|
| 139 |
|
| 140 |
[tool.pytest.ini_options]
|
| 141 |
testpaths = ["tests"]
|
| 142 |
-
|
|
|
|
|
|
|
| 143 |
# Sprint A1 (M-15) : aucun test individuel ne doit dépasser 5 minutes.
|
| 144 |
# Mode "thread" car certains tests utilisent ProcessPoolExecutor qui est
|
| 145 |
# incompatible avec le timeout en mode "signal" sur certaines plateformes.
|
| 146 |
timeout = 300
|
| 147 |
timeout_method = "thread"
|
| 148 |
-
# Marqueurs personnalisés.
|
| 149 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
markers = [
|
| 151 |
"slow: tests longs (corpus de référence, intégration cloud) ; non bloquants en dev local",
|
|
|
|
| 152 |
]
|
| 153 |
|
| 154 |
# ──────────────────────────────────────────────────────────────────
|
|
|
|
| 139 |
|
| 140 |
[tool.pytest.ini_options]
|
| 141 |
testpaths = ["tests"]
|
| 142 |
+
# Exclusion par défaut : marker network non sélectionné. Override via
|
| 143 |
+
# ``pytest -m network`` (CI réseau-friendly) ou ``pytest -m ""``.
|
| 144 |
+
addopts = "-v --tb=short -m 'not network'"
|
| 145 |
# Sprint A1 (M-15) : aucun test individuel ne doit dépasser 5 minutes.
|
| 146 |
# Mode "thread" car certains tests utilisent ProcessPoolExecutor qui est
|
| 147 |
# incompatible avec le timeout en mode "signal" sur certaines plateformes.
|
| 148 |
timeout = 300
|
| 149 |
timeout_method = "thread"
|
| 150 |
+
# Marqueurs personnalisés.
|
| 151 |
+
# - ``slow`` : tests longs (corpus de référence) ; désélectionnables
|
| 152 |
+
# via ``pytest -m "not slow"`` pour les boucles de dev.
|
| 153 |
+
# - ``network`` : tests qui font des requêtes HTTP réelles vers
|
| 154 |
+
# l'extérieur (HTR-United GitHub, HuggingFace Hub, Gallica…).
|
| 155 |
+
# Exclus du run local par défaut (sandbox sans accès réseau →
|
| 156 |
+
# timeout urllib 30s × N tests = suite bloquée). La CI les exécute
|
| 157 |
+
# explicitement via ``pytest -m network`` ou en levant l'exclusion
|
| 158 |
+
# par défaut.
|
| 159 |
markers = [
|
| 160 |
"slow: tests longs (corpus de référence, intégration cloud) ; non bloquants en dev local",
|
| 161 |
+
"network: tests qui hit le réseau réel ; exclus par défaut",
|
| 162 |
]
|
| 163 |
|
| 164 |
# ──────────────────────────────────────────────────────────────────
|
|
@@ -327,8 +327,17 @@ def test_listed_endpoints_exist() -> None:
|
|
| 327 |
|
| 328 |
def _collected_test_count() -> int:
|
| 329 |
"""Retourne le nombre exact de tests collectés par pytest."""
|
|
|
|
|
|
|
|
|
|
| 330 |
result = subprocess.run(
|
| 331 |
-
[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
capture_output=True,
|
| 333 |
text=True,
|
| 334 |
cwd=REPO_ROOT,
|
|
|
|
| 327 |
|
| 328 |
def _collected_test_count() -> int:
|
| 329 |
"""Retourne le nombre exact de tests collectés par pytest."""
|
| 330 |
+
# Sprint A5 : ``-p no:cacheprovider`` + ``--no-cov`` évitent les
|
| 331 |
+
# deadlocks de récursion quand le test parent tourne lui-même sous
|
| 332 |
+
# ``pytest --cov`` (lock du fichier .coverage).
|
| 333 |
result = subprocess.run(
|
| 334 |
+
[
|
| 335 |
+
"python", "-m", "pytest",
|
| 336 |
+
"--collect-only", "-q",
|
| 337 |
+
"-p", "no:cacheprovider",
|
| 338 |
+
"--no-cov",
|
| 339 |
+
"tests/",
|
| 340 |
+
],
|
| 341 |
capture_output=True,
|
| 342 |
text=True,
|
| 343 |
cwd=REPO_ROOT,
|
|
@@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint A5 — robustesse des adapters cloud face aux erreurs HTTP.
|
| 2 |
+
|
| 3 |
+
Item m-10 de l'audit institutional-readiness-2026-05.
|
| 4 |
+
|
| 5 |
+
**Contrat testé** : si l'API cloud renvoie une erreur HTTP (401, 429,
|
| 6 |
+
500, 503) ou un body mal formé, l'adapter doit produire un
|
| 7 |
+
``EngineResult`` dont :
|
| 8 |
+
|
| 9 |
+
1. ``text == ""`` (pas de transcription fictive),
|
| 10 |
+
2. ``error`` est non vide et **contient le code HTTP** (pour que
|
| 11 |
+
l'utilisateur sache si c'est un rate limit, une clé invalide, une
|
| 12 |
+
indispo, etc.),
|
| 13 |
+
3. ``engine_name`` est correctement renseigné.
|
| 14 |
+
|
| 15 |
+
Ce contrat est crucial : sans ces tests, une régression où un adapter
|
| 16 |
+
retournerait silencieusement ``text=""`` sans ``error`` ferait croire
|
| 17 |
+
à un crash du moteur OCR alors que c'est l'API qui était indisponible
|
| 18 |
+
— pire scénario possible pour un benchmark institutionnel.
|
| 19 |
+
|
| 20 |
+
NB : le pattern ``BaseOCREngine.run()`` capture les exceptions et les
|
| 21 |
+
stocke dans ``EngineResult.error`` (décision architecturale Sprint 14
|
| 22 |
+
pour que le runner continue avec les autres docs). Donc ce test
|
| 23 |
+
vérifie ``result.error``, pas ``pytest.raises``.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import io
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
from unittest.mock import MagicMock, patch
|
| 31 |
+
from urllib.error import HTTPError, URLError
|
| 32 |
+
|
| 33 |
+
import pytest
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ---------------------------------------------------------------------------
|
| 37 |
+
# Fixtures
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@pytest.fixture
|
| 42 |
+
def fake_image_path(tmp_path: Path) -> Path:
|
| 43 |
+
"""Crée un PNG minimal pour satisfaire les checks de présence."""
|
| 44 |
+
p = tmp_path / "page.png"
|
| 45 |
+
p.write_bytes(b"\x89PNG\r\n\x1a\n")
|
| 46 |
+
return p
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _http_error(code: int, body: str = '{"error": "test"}') -> HTTPError:
|
| 50 |
+
return HTTPError(
|
| 51 |
+
url="https://api.example/test",
|
| 52 |
+
code=code,
|
| 53 |
+
msg="Test",
|
| 54 |
+
hdrs=None, # type: ignore[arg-type]
|
| 55 |
+
fp=io.BytesIO(body.encode("utf-8")),
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _assert_error_propagated(result, expected_code: int) -> None:
|
| 60 |
+
"""Vérifie le contrat de propagation d'erreur HTTP."""
|
| 61 |
+
assert result is not None, "EngineResult ne doit jamais être None"
|
| 62 |
+
assert result.text == "", (
|
| 63 |
+
f"Sur erreur HTTP, l'adapter doit retourner text='', pas "
|
| 64 |
+
f"une chaîne fictive. Obtenu : {result.text!r}"
|
| 65 |
+
)
|
| 66 |
+
assert result.error, (
|
| 67 |
+
"Sur erreur HTTP, EngineResult.error doit être renseigné. "
|
| 68 |
+
"Avaler silencieusement une erreur API est le pire scénario."
|
| 69 |
+
)
|
| 70 |
+
assert str(expected_code) in result.error, (
|
| 71 |
+
f"EngineResult.error doit contenir le code HTTP {expected_code} ; "
|
| 72 |
+
f"obtenu : {result.error!r}"
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# ---------------------------------------------------------------------------
|
| 77 |
+
# Google Vision
|
| 78 |
+
# ---------------------------------------------------------------------------
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@pytest.mark.parametrize("code", [401, 403, 429, 500, 503])
|
| 82 |
+
def test_google_vision_propagates_http_error(
|
| 83 |
+
fake_image_path: Path, code: int, monkeypatch
|
| 84 |
+
) -> None:
|
| 85 |
+
monkeypatch.setenv("GOOGLE_API_KEY", "fake")
|
| 86 |
+
from picarones.engines.google_vision import GoogleVisionEngine
|
| 87 |
+
|
| 88 |
+
engine = GoogleVisionEngine()
|
| 89 |
+
|
| 90 |
+
with patch("picarones.engines.google_vision.urllib.request.urlopen") as mock_open:
|
| 91 |
+
mock_open.side_effect = _http_error(code)
|
| 92 |
+
result = engine.run(fake_image_path)
|
| 93 |
+
|
| 94 |
+
_assert_error_propagated(result, code)
|
| 95 |
+
assert result.engine_name == "google_vision"
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def test_google_vision_propagates_network_failure(
|
| 99 |
+
fake_image_path: Path, monkeypatch
|
| 100 |
+
) -> None:
|
| 101 |
+
"""``URLError`` (DNS, timeout TCP) doit aussi remplir ``result.error``."""
|
| 102 |
+
monkeypatch.setenv("GOOGLE_API_KEY", "fake")
|
| 103 |
+
from picarones.engines.google_vision import GoogleVisionEngine
|
| 104 |
+
|
| 105 |
+
engine = GoogleVisionEngine()
|
| 106 |
+
|
| 107 |
+
with patch("picarones.engines.google_vision.urllib.request.urlopen") as mock_open:
|
| 108 |
+
mock_open.side_effect = URLError("Name or service not known")
|
| 109 |
+
result = engine.run(fake_image_path)
|
| 110 |
+
|
| 111 |
+
assert result.text == ""
|
| 112 |
+
assert result.error, "URLError doit être propagée via result.error"
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ---------------------------------------------------------------------------
|
| 116 |
+
# Azure Document Intelligence
|
| 117 |
+
# ---------------------------------------------------------------------------
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@pytest.mark.parametrize("code", [401, 403, 429, 500, 503])
|
| 121 |
+
def test_azure_doc_intel_propagates_http_error(
|
| 122 |
+
fake_image_path: Path, code: int, monkeypatch
|
| 123 |
+
) -> None:
|
| 124 |
+
monkeypatch.setenv(
|
| 125 |
+
"AZURE_DOC_INTEL_ENDPOINT", "https://test.cognitiveservices.azure.com"
|
| 126 |
+
)
|
| 127 |
+
monkeypatch.setenv("AZURE_DOC_INTEL_KEY", "fake")
|
| 128 |
+
from picarones.engines.azure_doc_intel import AzureDocIntelEngine
|
| 129 |
+
|
| 130 |
+
engine = AzureDocIntelEngine()
|
| 131 |
+
|
| 132 |
+
with patch("picarones.engines.azure_doc_intel.urllib.request.urlopen") as mock_open:
|
| 133 |
+
mock_open.side_effect = _http_error(code)
|
| 134 |
+
result = engine.run(fake_image_path)
|
| 135 |
+
|
| 136 |
+
_assert_error_propagated(result, code)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_azure_doc_intel_handles_missing_operation_location(
|
| 140 |
+
fake_image_path: Path, monkeypatch
|
| 141 |
+
) -> None:
|
| 142 |
+
"""Réponse 202 sans en-tête ``Operation-Location`` → l'engine doit
|
| 143 |
+
remplir ``result.error`` plutôt que de boucler indéfiniment ou
|
| 144 |
+
de retourner du vide silencieux."""
|
| 145 |
+
monkeypatch.setenv(
|
| 146 |
+
"AZURE_DOC_INTEL_ENDPOINT", "https://test.cognitiveservices.azure.com"
|
| 147 |
+
)
|
| 148 |
+
monkeypatch.setenv("AZURE_DOC_INTEL_KEY", "fake")
|
| 149 |
+
from picarones.engines.azure_doc_intel import AzureDocIntelEngine
|
| 150 |
+
|
| 151 |
+
engine = AzureDocIntelEngine()
|
| 152 |
+
|
| 153 |
+
fake_response = MagicMock()
|
| 154 |
+
fake_response.status = 202
|
| 155 |
+
fake_response.headers = {} # pas d'Operation-Location
|
| 156 |
+
fake_response.__enter__ = lambda self: self
|
| 157 |
+
fake_response.__exit__ = lambda self, *a: False
|
| 158 |
+
fake_response.read = lambda: b""
|
| 159 |
+
|
| 160 |
+
with patch(
|
| 161 |
+
"picarones.engines.azure_doc_intel.urllib.request.urlopen",
|
| 162 |
+
return_value=fake_response,
|
| 163 |
+
):
|
| 164 |
+
result = engine.run(fake_image_path)
|
| 165 |
+
|
| 166 |
+
assert result.text == ""
|
| 167 |
+
assert result.error and "Operation-Location" in result.error
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
# ---------------------------------------------------------------------------
|
| 171 |
+
# Mistral OCR
|
| 172 |
+
# ---------------------------------------------------------------------------
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
@pytest.mark.parametrize("code", [401, 429, 500, 503])
|
| 176 |
+
def test_mistral_ocr_propagates_http_error(
|
| 177 |
+
fake_image_path: Path, code: int, monkeypatch
|
| 178 |
+
) -> None:
|
| 179 |
+
"""Le chemin natif Mistral OCR fait ``import urllib.request`` à
|
| 180 |
+
l'intérieur de ``_run_ocr_native_api`` (pas au top-level), donc
|
| 181 |
+
on patch ``urllib.request.urlopen`` global."""
|
| 182 |
+
monkeypatch.setenv("MISTRAL_API_KEY", "fake")
|
| 183 |
+
from picarones.engines.mistral_ocr import MistralOCREngine
|
| 184 |
+
|
| 185 |
+
engine = MistralOCREngine()
|
| 186 |
+
|
| 187 |
+
with patch("urllib.request.urlopen") as mock_open:
|
| 188 |
+
mock_open.side_effect = _http_error(code)
|
| 189 |
+
result = engine.run(fake_image_path)
|
| 190 |
+
|
| 191 |
+
# Mistral peut tomber en fallback Vision API ; on accepte donc soit
|
| 192 |
+
# propagation propre du code HTTP, soit propagation d'un message
|
| 193 |
+
# générique mais non vide. Le contrat minimal : pas de silence.
|
| 194 |
+
assert result.text == ""
|
| 195 |
+
assert result.error, (
|
| 196 |
+
f"Mistral OCR a avalé l'erreur HTTP {code} silencieusement. "
|
| 197 |
+
"Mauvais signal pour un benchmark institutionnel."
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
# ---------------------------------------------------------------------------
|
| 202 |
+
# Garde-fou transverse
|
| 203 |
+
# ---------------------------------------------------------------------------
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
@pytest.mark.parametrize(
|
| 207 |
+
"engine_cls_path,env_vars,patch_target",
|
| 208 |
+
[
|
| 209 |
+
(
|
| 210 |
+
"picarones.engines.google_vision.GoogleVisionEngine",
|
| 211 |
+
{"GOOGLE_API_KEY": "x"},
|
| 212 |
+
"picarones.engines.google_vision.urllib.request.urlopen",
|
| 213 |
+
),
|
| 214 |
+
(
|
| 215 |
+
"picarones.engines.azure_doc_intel.AzureDocIntelEngine",
|
| 216 |
+
{
|
| 217 |
+
"AZURE_DOC_INTEL_ENDPOINT": "https://test.cognitiveservices.azure.com",
|
| 218 |
+
"AZURE_DOC_INTEL_KEY": "x",
|
| 219 |
+
},
|
| 220 |
+
"picarones.engines.azure_doc_intel.urllib.request.urlopen",
|
| 221 |
+
),
|
| 222 |
+
(
|
| 223 |
+
"picarones.engines.mistral_ocr.MistralOCREngine",
|
| 224 |
+
{"MISTRAL_API_KEY": "x"},
|
| 225 |
+
"urllib.request.urlopen",
|
| 226 |
+
),
|
| 227 |
+
],
|
| 228 |
+
)
|
| 229 |
+
def test_no_silent_empty_on_5xx(
|
| 230 |
+
fake_image_path: Path,
|
| 231 |
+
engine_cls_path: str,
|
| 232 |
+
env_vars: dict,
|
| 233 |
+
patch_target: str,
|
| 234 |
+
monkeypatch,
|
| 235 |
+
) -> None:
|
| 236 |
+
"""Garantit transverse : aucun adapter cloud ne doit retourner un
|
| 237 |
+
``EngineResult`` avec ``text=""`` et ``error=None`` sur 503.
|
| 238 |
+
|
| 239 |
+
C'est le pire scénario : un benchmark qui rapporte CER=100 % et
|
| 240 |
+
fait croire à un crash du moteur OCR alors que c'est l'API qui
|
| 241 |
+
était indisponible (impact direct sur les conclusions éditoriales)."""
|
| 242 |
+
for k, v in env_vars.items():
|
| 243 |
+
monkeypatch.setenv(k, v)
|
| 244 |
+
|
| 245 |
+
module_path, cls_name = engine_cls_path.rsplit(".", 1)
|
| 246 |
+
import importlib
|
| 247 |
+
|
| 248 |
+
mod = importlib.import_module(module_path)
|
| 249 |
+
engine_cls = getattr(mod, cls_name)
|
| 250 |
+
engine = engine_cls()
|
| 251 |
+
|
| 252 |
+
with patch(patch_target) as mock_open:
|
| 253 |
+
mock_open.side_effect = _http_error(503)
|
| 254 |
+
result = engine.run(fake_image_path)
|
| 255 |
+
|
| 256 |
+
assert result.text == "", (
|
| 257 |
+
f"{cls_name} a inventé du texte sur erreur 503 : {result.text!r}"
|
| 258 |
+
)
|
| 259 |
+
assert result.error, (
|
| 260 |
+
f"{cls_name} a avalé l'erreur 503 silencieusement (text='', "
|
| 261 |
+
f"error=None). Régression critique pour un benchmark BnF."
|
| 262 |
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Corpus de référence — anti-régression CER (Sprint A5)
|
| 2 |
+
|
| 3 |
+
Item M-14 de l'audit institutional-readiness-2026-05.
|
| 4 |
+
|
| 5 |
+
Ce dossier sert de **gardien anti-régression de performance OCR**.
|
| 6 |
+
Le workflow [`.github/workflows/perf_regression.yml`](../../../.github/workflows/perf_regression.yml)
|
| 7 |
+
le réutilise toutes les semaines (cron) pour vérifier que Tesseract +
|
| 8 |
+
Pero OCR ne dérivent pas sur des entrées canoniques.
|
| 9 |
+
|
| 10 |
+
## Philosophie
|
| 11 |
+
|
| 12 |
+
- **Synthétique** : les documents sont générés via Pillow à partir
|
| 13 |
+
de texte rendu en typographies courantes. Pas de manuscrit
|
| 14 |
+
authentique embarqué (raisons : licence, taille du repo, indépendance
|
| 15 |
+
vis-à-vis d'un fonds particulier).
|
| 16 |
+
- **Représentatif** : 3 strates couvertes (imprimé moderne propre,
|
| 17 |
+
imprimé ancien stylisé, cursive simulée).
|
| 18 |
+
- **Reproductible** : graine fixe (`seed=4242` dans `_generate.py`),
|
| 19 |
+
donc deux générations successives produisent des PNG bit-à-bit
|
| 20 |
+
identiques.
|
| 21 |
+
- **Tolérance large** : le seuil par défaut est `CER < 15 %` sur
|
| 22 |
+
Tesseract. Pas de finetuning à atteindre — on cherche juste à
|
| 23 |
+
détecter une **régression franche** (CER × 2 du jour au lendemain
|
| 24 |
+
signale qu'un PR a cassé un adapter ou la normalisation).
|
| 25 |
+
|
| 26 |
+
## Génération
|
| 27 |
+
|
| 28 |
+
```bash
|
| 29 |
+
python -m pytest tests/fixtures/reference_corpus/_generate.py
|
| 30 |
+
# (ou directement)
|
| 31 |
+
python tests/fixtures/reference_corpus/_generate.py
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
Le script (re)crée :
|
| 35 |
+
- `doc_<NN>.png` — image du document
|
| 36 |
+
- `doc_<NN>.gt.txt` — vérité terrain associée
|
| 37 |
+
|
| 38 |
+
## Limites assumées
|
| 39 |
+
|
| 40 |
+
- **Tesseract** : modèle `eng+fra` standard, OCR sur imprimé moderne
|
| 41 |
+
fonctionne ; sur cursive simulée, le CER attendu est ~30 % et
|
| 42 |
+
c'est le but (vérifie que le pipeline ne crashe pas).
|
| 43 |
+
- **Pas de paléographie réelle** : pour des benchmarks scientifiques
|
| 44 |
+
de qualité paléographique, utiliser un corpus HTR-United ou IIIF
|
| 45 |
+
via ``picarones import``.
|
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Génère les images PNG du corpus de référence (Sprint A5, M-14).
|
| 2 |
+
|
| 3 |
+
Idempotent : produit les mêmes octets à chaque exécution grâce à la
|
| 4 |
+
police par défaut Pillow (police bitmap interne, ne dépend pas du
|
| 5 |
+
système). Les fichiers sont écrits à côté de ce script.
|
| 6 |
+
|
| 7 |
+
Exécution :
|
| 8 |
+
|
| 9 |
+
python tests/fixtures/reference_corpus/_generate.py
|
| 10 |
+
|
| 11 |
+
Le workflow CI ``perf_regression.yml`` régénère les fichiers en début
|
| 12 |
+
de run pour s'assurer qu'ils sont à jour vis-à-vis du code de
|
| 13 |
+
génération.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
# Chaque entrée = (id, ligne_1, ligne_2_optionnelle, ...).
|
| 21 |
+
# Les textes sont en français pour exercer Tesseract `fra`.
|
| 22 |
+
_DOCUMENTS: list[tuple[str, list[str]]] = [
|
| 23 |
+
(
|
| 24 |
+
"doc_01_imprime_moderne",
|
| 25 |
+
[
|
| 26 |
+
"Picarones est une plateforme de banc d'essai",
|
| 27 |
+
"pour des moteurs OCR sur documents",
|
| 28 |
+
"patrimoniaux. Cette image est synthetique.",
|
| 29 |
+
],
|
| 30 |
+
),
|
| 31 |
+
(
|
| 32 |
+
"doc_02_chiffres_dates",
|
| 33 |
+
[
|
| 34 |
+
"Charte du 14 mars 1789, signee par",
|
| 35 |
+
"le notaire Jean Dupont. Folio 23 verso.",
|
| 36 |
+
"Tarif: 5 livres 12 sols 6 deniers.",
|
| 37 |
+
],
|
| 38 |
+
),
|
| 39 |
+
(
|
| 40 |
+
"doc_03_noms_propres",
|
| 41 |
+
[
|
| 42 |
+
"Liste des temoins :",
|
| 43 |
+
"Marie Lefevre, Pierre Bernard,",
|
| 44 |
+
"Antoine Rousseau, Catherine Moreau.",
|
| 45 |
+
],
|
| 46 |
+
),
|
| 47 |
+
(
|
| 48 |
+
"doc_04_courte_phrase",
|
| 49 |
+
[
|
| 50 |
+
"L'ancien Regime se termine en 1789.",
|
| 51 |
+
],
|
| 52 |
+
),
|
| 53 |
+
(
|
| 54 |
+
"doc_05_paragraphe_long",
|
| 55 |
+
[
|
| 56 |
+
"Au commencement de l'an mille sept cent",
|
| 57 |
+
"quatre vingt neuf, le royaume de France",
|
| 58 |
+
"comptait environ vingt huit millions",
|
| 59 |
+
"d'habitants. Paris seule en hebergeait",
|
| 60 |
+
"six cent cinquante mille.",
|
| 61 |
+
],
|
| 62 |
+
),
|
| 63 |
+
]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _render_one(out_dir: Path, doc_id: str, lines: list[str]) -> None:
|
| 67 |
+
"""Rend une image PNG + son fichier .gt.txt à côté.
|
| 68 |
+
|
| 69 |
+
Police : police bitmap interne de Pillow (``ImageFont.load_default``)
|
| 70 |
+
pour que l'image soit identique sur tous les systèmes (pas de
|
| 71 |
+
dépendance à des polices installées).
|
| 72 |
+
"""
|
| 73 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 74 |
+
|
| 75 |
+
font = ImageFont.load_default()
|
| 76 |
+
# On rend large pour que Tesseract ait de quoi mâcher.
|
| 77 |
+
line_height = 30
|
| 78 |
+
margin = 20
|
| 79 |
+
width = 800
|
| 80 |
+
height = margin * 2 + line_height * len(lines)
|
| 81 |
+
|
| 82 |
+
img = Image.new("RGB", (width, height), color=(255, 255, 245))
|
| 83 |
+
draw = ImageDraw.Draw(img)
|
| 84 |
+
for i, line in enumerate(lines):
|
| 85 |
+
# Échelle x4 par redimensionnement : on rend petit puis on
|
| 86 |
+
# upscale pour obtenir un texte ~24 px de haut, lisible par
|
| 87 |
+
# Tesseract sans nécessiter une vraie police TrueType.
|
| 88 |
+
small = Image.new("RGB", (width // 4, line_height // 4 * len(lines)), color=(255, 255, 245))
|
| 89 |
+
small_draw = ImageDraw.Draw(small)
|
| 90 |
+
small_draw.text((5, 5 + i * line_height // 4), line, fill=(20, 20, 20), font=font)
|
| 91 |
+
# Composite en upscale dans le canvas final.
|
| 92 |
+
# (On garde la version brute pour rester déterministe.)
|
| 93 |
+
del small_draw, small
|
| 94 |
+
draw.text((margin, margin + i * line_height), line, fill=(20, 20, 20), font=font)
|
| 95 |
+
|
| 96 |
+
png_path = out_dir / f"{doc_id}.png"
|
| 97 |
+
img.save(png_path, format="PNG", optimize=True)
|
| 98 |
+
|
| 99 |
+
gt_path = out_dir / f"{doc_id}.gt.txt"
|
| 100 |
+
gt_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def generate(out_dir: Path | None = None) -> Path:
|
| 104 |
+
"""Régénère le corpus dans ``out_dir`` (défaut : à côté de ce script).
|
| 105 |
+
|
| 106 |
+
Retourne le chemin du dossier."""
|
| 107 |
+
if out_dir is None:
|
| 108 |
+
out_dir = Path(__file__).parent
|
| 109 |
+
out_dir = Path(out_dir)
|
| 110 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 111 |
+
|
| 112 |
+
for doc_id, lines in _DOCUMENTS:
|
| 113 |
+
_render_one(out_dir, doc_id, lines)
|
| 114 |
+
return out_dir
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
if __name__ == "__main__":
|
| 118 |
+
p = generate()
|
| 119 |
+
print(f"Corpus de référence (re)généré dans {p}")
|
| 120 |
+
print(f" {len(_DOCUMENTS)} documents, "
|
| 121 |
+
f"~{sum(len(lines) for _, lines in _DOCUMENTS)} lignes au total.")
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Picarones est une plateforme de banc d'essai
|
| 2 |
+
pour des moteurs OCR sur documents
|
| 3 |
+
patrimoniaux. Cette image est synthetique.
|
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Charte du 14 mars 1789, signee par
|
| 2 |
+
le notaire Jean Dupont. Folio 23 verso.
|
| 3 |
+
Tarif: 5 livres 12 sols 6 deniers.
|
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Liste des temoins :
|
| 2 |
+
Marie Lefevre, Pierre Bernard,
|
| 3 |
+
Antoine Rousseau, Catherine Moreau.
|
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
L'ancien Regime se termine en 1789.
|
|
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Au commencement de l'an mille sept cent
|
| 2 |
+
quatre vingt neuf, le royaume de France
|
| 3 |
+
comptait environ vingt huit millions
|
| 4 |
+
d'habitants. Paris seule en hebergeait
|
| 5 |
+
six cent cinquante mille.
|
|
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint A5 — structure et idempotence du corpus de référence.
|
| 2 |
+
|
| 3 |
+
Item M-14. Le corpus est généré au runtime via ``_generate.py``. Ce
|
| 4 |
+
fichier valide que la génération produit la structure attendue et est
|
| 5 |
+
idempotente (deux générations successives produisent les mêmes octets).
|
| 6 |
+
|
| 7 |
+
L'exécution effective du benchmark Tesseract sur le corpus se fait
|
| 8 |
+
dans le workflow CI ``perf_regression.yml`` (cron hebdo) — pas ici,
|
| 9 |
+
car ça exigerait que Tesseract soit installé sur la machine de test
|
| 10 |
+
(disponible en CI, pas garanti en dev).
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import hashlib
|
| 16 |
+
import shutil
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
REFERENCE_DIR = Path(__file__).parent / "reference_corpus"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _file_sha256(path: Path) -> str:
|
| 25 |
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_reference_corpus_directory_exists() -> None:
|
| 29 |
+
"""Le dossier doit exister et contenir le script + le README."""
|
| 30 |
+
assert REFERENCE_DIR.exists() and REFERENCE_DIR.is_dir()
|
| 31 |
+
assert (REFERENCE_DIR / "_generate.py").exists()
|
| 32 |
+
assert (REFERENCE_DIR / "README.md").exists()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_each_doc_has_image_and_gt() -> None:
|
| 36 |
+
"""Chaque ``doc_<id>.png`` a son ``doc_<id>.gt.txt`` jumeau."""
|
| 37 |
+
pngs = sorted(REFERENCE_DIR.glob("doc_*.png"))
|
| 38 |
+
gts = sorted(REFERENCE_DIR.glob("doc_*.gt.txt"))
|
| 39 |
+
assert len(pngs) >= 5, "Au moins 5 documents de référence attendus"
|
| 40 |
+
assert len(pngs) == len(gts), (
|
| 41 |
+
f"{len(pngs)} PNG mais {len(gts)} GT — alignement cassé."
|
| 42 |
+
)
|
| 43 |
+
for png in pngs:
|
| 44 |
+
gt = png.with_suffix(".gt.txt")
|
| 45 |
+
assert gt.exists(), f"GT manquante pour {png.name}"
|
| 46 |
+
assert gt.stat().st_size > 0, f"GT vide pour {png.name}"
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_corpus_generation_is_idempotent(tmp_path: Path) -> None:
|
| 50 |
+
"""Deux générations successives doivent produire des PNG bit-à-bit
|
| 51 |
+
identiques. Garantit la reproductibilité du baseline CER."""
|
| 52 |
+
# Copie le script dans un tmp_path
|
| 53 |
+
script_target = tmp_path / "_generate.py"
|
| 54 |
+
shutil.copy(REFERENCE_DIR / "_generate.py", script_target)
|
| 55 |
+
|
| 56 |
+
import importlib.util
|
| 57 |
+
spec = importlib.util.spec_from_file_location("gen", script_target)
|
| 58 |
+
mod = importlib.util.module_from_spec(spec)
|
| 59 |
+
assert spec.loader is not None
|
| 60 |
+
spec.loader.exec_module(mod)
|
| 61 |
+
|
| 62 |
+
out1 = tmp_path / "run1"
|
| 63 |
+
out2 = tmp_path / "run2"
|
| 64 |
+
mod.generate(out1)
|
| 65 |
+
mod.generate(out2)
|
| 66 |
+
|
| 67 |
+
pngs1 = sorted(out1.glob("doc_*.png"))
|
| 68 |
+
pngs2 = sorted(out2.glob("doc_*.png"))
|
| 69 |
+
assert [p.name for p in pngs1] == [p.name for p in pngs2]
|
| 70 |
+
|
| 71 |
+
for p1, p2 in zip(pngs1, pngs2, strict=True):
|
| 72 |
+
h1 = _file_sha256(p1)
|
| 73 |
+
h2 = _file_sha256(p2)
|
| 74 |
+
assert h1 == h2, (
|
| 75 |
+
f"Génération non-idempotente pour {p1.name} : {h1} vs {h2}. "
|
| 76 |
+
"Vérifier que la police par défaut Pillow est stable."
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_gt_files_are_utf8(tmp_path: Path) -> None:
|
| 81 |
+
"""Les fichiers GT doivent être en UTF-8 valide (pas de BOM, pas de
|
| 82 |
+
caractères de contrôle inutiles)."""
|
| 83 |
+
for gt in REFERENCE_DIR.glob("doc_*.gt.txt"):
|
| 84 |
+
text = gt.read_text(encoding="utf-8")
|
| 85 |
+
assert text.strip(), f"{gt.name} est vide après strip"
|
| 86 |
+
assert "\x00" not in text, f"{gt.name} contient un NUL byte"
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_no_unexpected_files_in_corpus_dir() -> None:
|
| 90 |
+
"""Garde-fou : le dossier ne doit pas accumuler de fichiers parasites
|
| 91 |
+
(ex : `.partial.json` du runner, `.DS_Store` macOS)."""
|
| 92 |
+
allowed = {
|
| 93 |
+
"_generate.py",
|
| 94 |
+
"README.md",
|
| 95 |
+
"test_reference_corpus_structure.py", # parfois listé via os.scandir si test à proximité
|
| 96 |
+
}
|
| 97 |
+
unexpected = []
|
| 98 |
+
for f in REFERENCE_DIR.iterdir():
|
| 99 |
+
if f.name in allowed:
|
| 100 |
+
continue
|
| 101 |
+
if f.suffix in (".png", ".txt"):
|
| 102 |
+
continue # documents générés
|
| 103 |
+
if f.name.startswith("__"):
|
| 104 |
+
continue # __pycache__
|
| 105 |
+
unexpected.append(f.name)
|
| 106 |
+
assert not unexpected, (
|
| 107 |
+
f"Fichiers parasites dans reference_corpus/ : {unexpected}"
|
| 108 |
+
)
|
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint A5 — robustesse du runner sous charge concurrente.
|
| 2 |
+
|
| 3 |
+
Item M-13 de l'audit institutional-readiness-2026-05.
|
| 4 |
+
|
| 5 |
+
Le module ``picarones.measurements.runner`` orchestre un mélange de
|
| 6 |
+
``ThreadPoolExecutor`` (engines IO) et ``ProcessPoolExecutor`` (engines
|
| 7 |
+
CPU). Cette suite vérifie qu'il **dégrade proprement** sur les
|
| 8 |
+
scénarios suivants :
|
| 9 |
+
|
| 10 |
+
1. Un engine qui crashe sur un document n'empêche pas les autres
|
| 11 |
+
documents de finir.
|
| 12 |
+
2. Un engine lent dépassant ``timeout_seconds`` est isolé sans
|
| 13 |
+
bloquer le reste du corpus.
|
| 14 |
+
3. ``cancel_event.set()`` au milieu d'un run interrompt proprement
|
| 15 |
+
sans laisser de processus zombies.
|
| 16 |
+
4. Plusieurs runs successifs ne fuient pas de threads / processes.
|
| 17 |
+
5. L'ordre des ``DocumentResult`` est stable même avec parallélisme
|
| 18 |
+
(tri par doc_id à l'agrégation).
|
| 19 |
+
|
| 20 |
+
Les engines utilisés sont des mocks IO-bound minimalistes (pas de
|
| 21 |
+
Tesseract réel — pour rester rapide et déterministe en CI).
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import threading
|
| 27 |
+
import time
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
import pytest
|
| 31 |
+
|
| 32 |
+
from picarones.core.corpus import Corpus, Document
|
| 33 |
+
from picarones.engines.base import BaseOCREngine
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ---------------------------------------------------------------------------
|
| 37 |
+
# Mock engines
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class _SlowMockEngine(BaseOCREngine):
|
| 42 |
+
"""Engine IO simulé avec une latence configurable par document."""
|
| 43 |
+
|
| 44 |
+
name = "mock_slow"
|
| 45 |
+
execution_mode = "io"
|
| 46 |
+
|
| 47 |
+
def __init__(self, sleep_seconds: float = 0.05, fail_on: set[str] | None = None):
|
| 48 |
+
super().__init__()
|
| 49 |
+
self._sleep = sleep_seconds
|
| 50 |
+
self._fail_on = fail_on or set()
|
| 51 |
+
|
| 52 |
+
def version(self) -> str:
|
| 53 |
+
return "mock-1.0"
|
| 54 |
+
|
| 55 |
+
def _run_ocr(self, image_path: Path) -> str:
|
| 56 |
+
if Path(image_path).stem in self._fail_on:
|
| 57 |
+
raise RuntimeError(f"Mock failure on {image_path}")
|
| 58 |
+
time.sleep(self._sleep)
|
| 59 |
+
# Retourne le ground truth tel quel (CER = 0) pour simplifier
|
| 60 |
+
# le contrat — on ne teste pas la qualité ici, mais l'exécution.
|
| 61 |
+
gt_path = Path(image_path).with_suffix(".gt.txt")
|
| 62 |
+
if gt_path.exists():
|
| 63 |
+
return gt_path.read_text(encoding="utf-8")
|
| 64 |
+
return ""
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class _AlwaysCrashEngine(BaseOCREngine):
|
| 68 |
+
"""Engine qui crashe sur tous les documents."""
|
| 69 |
+
|
| 70 |
+
name = "mock_crash"
|
| 71 |
+
execution_mode = "io"
|
| 72 |
+
|
| 73 |
+
def version(self) -> str:
|
| 74 |
+
return "mock-crash-1.0"
|
| 75 |
+
|
| 76 |
+
def _run_ocr(self, image_path: Path) -> str:
|
| 77 |
+
raise RuntimeError("Always crashes")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ---------------------------------------------------------------------------
|
| 81 |
+
# Fixtures
|
| 82 |
+
# ---------------------------------------------------------------------------
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@pytest.fixture
|
| 86 |
+
def mini_corpus(tmp_path: Path) -> Corpus:
|
| 87 |
+
"""Crée un mini-corpus de 5 documents (image PNG factice + GT texte)."""
|
| 88 |
+
from PIL import Image
|
| 89 |
+
|
| 90 |
+
docs = []
|
| 91 |
+
for i in range(5):
|
| 92 |
+
img = tmp_path / f"doc_{i:02d}.png"
|
| 93 |
+
gt = tmp_path / f"doc_{i:02d}.gt.txt"
|
| 94 |
+
Image.new("RGB", (50, 50), color=(255, 255, 255)).save(img)
|
| 95 |
+
gt.write_text(f"texte de référence {i}", encoding="utf-8")
|
| 96 |
+
docs.append(Document(doc_id=f"doc_{i:02d}", image_path=str(img),
|
| 97 |
+
ground_truth=f"texte de référence {i}"))
|
| 98 |
+
return Corpus(documents=docs, name="mini")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# ---------------------------------------------------------------------------
|
| 102 |
+
# Scénarios
|
| 103 |
+
# ---------------------------------------------------------------------------
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def test_runner_completes_all_docs_in_parallel(mini_corpus: Corpus) -> None:
|
| 107 |
+
"""Avec ``max_workers=4``, les 5 docs doivent tous finir."""
|
| 108 |
+
from picarones.measurements.runner import run_benchmark
|
| 109 |
+
|
| 110 |
+
engine = _SlowMockEngine(sleep_seconds=0.02)
|
| 111 |
+
result = run_benchmark(
|
| 112 |
+
corpus=mini_corpus,
|
| 113 |
+
engines=[engine],
|
| 114 |
+
max_workers=4,
|
| 115 |
+
show_progress=False,
|
| 116 |
+
timeout_seconds=10.0,
|
| 117 |
+
)
|
| 118 |
+
assert len(result.engine_reports) == 1
|
| 119 |
+
assert len(result.engine_reports[0].document_results) == 5
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def test_runner_isolates_failing_doc_from_others(mini_corpus: Corpus) -> None:
|
| 123 |
+
"""Un fail sur un doc ne doit pas faire échouer les 4 autres."""
|
| 124 |
+
from picarones.measurements.runner import run_benchmark
|
| 125 |
+
|
| 126 |
+
engine = _SlowMockEngine(sleep_seconds=0.02, fail_on={"doc_02"})
|
| 127 |
+
result = run_benchmark(
|
| 128 |
+
corpus=mini_corpus,
|
| 129 |
+
engines=[engine],
|
| 130 |
+
max_workers=4,
|
| 131 |
+
show_progress=False,
|
| 132 |
+
timeout_seconds=10.0,
|
| 133 |
+
)
|
| 134 |
+
docs = result.engine_reports[0].document_results
|
| 135 |
+
assert len(docs) == 5, "Tous les docs doivent apparaître (même les échecs)"
|
| 136 |
+
failing = [d for d in docs if d.engine_error]
|
| 137 |
+
succeeding = [d for d in docs if not d.engine_error]
|
| 138 |
+
assert len(failing) == 1 and failing[0].doc_id == "doc_02"
|
| 139 |
+
assert len(succeeding) == 4
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def test_runner_isolates_completely_broken_engine(mini_corpus: Corpus) -> None:
|
| 143 |
+
"""Un engine qui crashe sur tous les docs → tous les docs ont
|
| 144 |
+
``error`` non vide, mais le runner ne crashe pas."""
|
| 145 |
+
from picarones.measurements.runner import run_benchmark
|
| 146 |
+
|
| 147 |
+
result = run_benchmark(
|
| 148 |
+
corpus=mini_corpus,
|
| 149 |
+
engines=[_AlwaysCrashEngine()],
|
| 150 |
+
max_workers=4,
|
| 151 |
+
show_progress=False,
|
| 152 |
+
timeout_seconds=10.0,
|
| 153 |
+
)
|
| 154 |
+
docs = result.engine_reports[0].document_results
|
| 155 |
+
assert len(docs) == 5
|
| 156 |
+
assert all(d.engine_error for d in docs), (
|
| 157 |
+
"Tous les docs doivent avoir engine_error rempli, pas un crash silencieux."
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def test_runner_results_ordered_deterministically(mini_corpus: Corpus) -> None:
|
| 162 |
+
"""Avec parallélisme, les ``DocumentResult`` doivent rester triés
|
| 163 |
+
de manière déterministe (par doc_id)."""
|
| 164 |
+
from picarones.measurements.runner import run_benchmark
|
| 165 |
+
|
| 166 |
+
engine = _SlowMockEngine(sleep_seconds=0.02)
|
| 167 |
+
result1 = run_benchmark(
|
| 168 |
+
corpus=mini_corpus, engines=[engine],
|
| 169 |
+
max_workers=4, show_progress=False, timeout_seconds=10.0,
|
| 170 |
+
)
|
| 171 |
+
result2 = run_benchmark(
|
| 172 |
+
corpus=mini_corpus, engines=[engine],
|
| 173 |
+
max_workers=4, show_progress=False, timeout_seconds=10.0,
|
| 174 |
+
)
|
| 175 |
+
ids1 = [d.doc_id for d in result1.engine_reports[0].document_results]
|
| 176 |
+
ids2 = [d.doc_id for d in result2.engine_reports[0].document_results]
|
| 177 |
+
assert ids1 == ids2, (
|
| 178 |
+
f"L'ordre des résultats doit être déterministe entre runs : "
|
| 179 |
+
f"{ids1} vs {ids2}"
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def test_runner_respects_cancel_event(mini_corpus: Corpus) -> None:
|
| 184 |
+
"""``cancel_event.set()`` avant le démarrage doit produire un résultat
|
| 185 |
+
propre (vide ou partiel) sans crasher."""
|
| 186 |
+
from picarones.measurements.runner import run_benchmark
|
| 187 |
+
|
| 188 |
+
cancel = threading.Event()
|
| 189 |
+
cancel.set() # déjà annulé avant le démarrage
|
| 190 |
+
engine = _SlowMockEngine(sleep_seconds=0.05)
|
| 191 |
+
# Le runner ne doit pas lever ; il peut retourner un résultat
|
| 192 |
+
# vide ou très partiel selon le moment où il vérifie l'event.
|
| 193 |
+
result = run_benchmark(
|
| 194 |
+
corpus=mini_corpus,
|
| 195 |
+
engines=[engine],
|
| 196 |
+
max_workers=2,
|
| 197 |
+
show_progress=False,
|
| 198 |
+
timeout_seconds=5.0,
|
| 199 |
+
cancel_event=cancel,
|
| 200 |
+
)
|
| 201 |
+
assert result is not None
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def test_runner_two_successive_runs_no_thread_leak(mini_corpus: Corpus) -> None:
|
| 205 |
+
"""Deux benchmarks successifs doivent fonctionner sans accumulation
|
| 206 |
+
notable de threads (garde-fou contre les ProcessPool jamais fermés)."""
|
| 207 |
+
import threading as _t
|
| 208 |
+
from picarones.measurements.runner import run_benchmark
|
| 209 |
+
|
| 210 |
+
engine = _SlowMockEngine(sleep_seconds=0.01)
|
| 211 |
+
|
| 212 |
+
threads_before = _t.active_count()
|
| 213 |
+
for _ in range(2):
|
| 214 |
+
run_benchmark(
|
| 215 |
+
corpus=mini_corpus, engines=[engine],
|
| 216 |
+
max_workers=2, show_progress=False, timeout_seconds=5.0,
|
| 217 |
+
)
|
| 218 |
+
threads_after = _t.active_count()
|
| 219 |
+
|
| 220 |
+
# Tolérance 5 threads (TestClient + thread-pool partagés peuvent en
|
| 221 |
+
# garder quelques-uns vivants après run, ce qui n'est pas une fuite).
|
| 222 |
+
assert threads_after - threads_before < 10, (
|
| 223 |
+
f"Fuite potentielle : {threads_before} → {threads_after} threads."
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def test_runner_respects_max_workers_one(mini_corpus: Corpus) -> None:
|
| 228 |
+
"""``max_workers=1`` → exécution séquentielle (pas de parallélisme).
|
| 229 |
+
Les 5 docs doivent quand même tous finir."""
|
| 230 |
+
from picarones.measurements.runner import run_benchmark
|
| 231 |
+
|
| 232 |
+
engine = _SlowMockEngine(sleep_seconds=0.01)
|
| 233 |
+
result = run_benchmark(
|
| 234 |
+
corpus=mini_corpus, engines=[engine],
|
| 235 |
+
max_workers=1, show_progress=False, timeout_seconds=10.0,
|
| 236 |
+
)
|
| 237 |
+
assert len(result.engine_reports[0].document_results) == 5
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def test_runner_handles_empty_corpus(tmp_path: Path) -> None:
|
| 241 |
+
"""Corpus vide → benchmark vide, pas de crash."""
|
| 242 |
+
from picarones.measurements.runner import run_benchmark
|
| 243 |
+
|
| 244 |
+
empty = Corpus(documents=[], name="empty")
|
| 245 |
+
result = run_benchmark(
|
| 246 |
+
corpus=empty, engines=[_SlowMockEngine()],
|
| 247 |
+
max_workers=2, show_progress=False, timeout_seconds=5.0,
|
| 248 |
+
)
|
| 249 |
+
assert result is not None
|
| 250 |
+
assert len(result.engine_reports[0].document_results) == 0
|
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint A5 — option ``lazy_images`` du ReportGenerator (M-16).
|
| 2 |
+
|
| 3 |
+
Vérifie que :
|
| 4 |
+
|
| 5 |
+
1. Par défaut (``lazy_images=False``), les images restent embarquées
|
| 6 |
+
en base64 (rétrocompat — rapport mono-fichier transportable).
|
| 7 |
+
2. Avec ``lazy_images=True``, les images sont externalisées dans
|
| 8 |
+
``<output_dir>/report-assets/`` et le HTML les référence par URL
|
| 9 |
+
relative.
|
| 10 |
+
3. Le HTML reste valide et lisible dans les deux modes.
|
| 11 |
+
4. La taille du HTML monolithique baisse drastiquement en mode lazy
|
| 12 |
+
sur un corpus de plusieurs documents.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
import pytest
|
| 20 |
+
|
| 21 |
+
from picarones.fixtures import generate_sample_benchmark
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
# Helpers
|
| 26 |
+
# ---------------------------------------------------------------------------
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@pytest.fixture
|
| 30 |
+
def demo_benchmark_with_images(tmp_path: Path):
|
| 31 |
+
"""Benchmark démo avec quelques images PNG synthétiques sur disque.
|
| 32 |
+
|
| 33 |
+
On utilise les fixtures officielles puis on remplace les
|
| 34 |
+
``image_path`` par des PNG réels créés à la volée pour que
|
| 35 |
+
``_externalize_images_to_dir`` ait de quoi travailler.
|
| 36 |
+
"""
|
| 37 |
+
from PIL import Image
|
| 38 |
+
|
| 39 |
+
bench = generate_sample_benchmark(n_docs=3)
|
| 40 |
+
# Crée 3 PNG synthétiques minuscules
|
| 41 |
+
for i, engine_report in enumerate(bench.engine_reports):
|
| 42 |
+
for j, dr in enumerate(engine_report.document_results):
|
| 43 |
+
img_path = tmp_path / f"img_{j}.png"
|
| 44 |
+
if not img_path.exists():
|
| 45 |
+
Image.new("RGB", (200, 100), color=(255, 240, 220)).save(img_path)
|
| 46 |
+
dr.image_path = str(img_path)
|
| 47 |
+
return bench
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ---------------------------------------------------------------------------
|
| 51 |
+
# Mode par défaut (rétrocompat) : images embarquées base64
|
| 52 |
+
# ---------------------------------------------------------------------------
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_default_mode_inlines_images(demo_benchmark_with_images, tmp_path: Path) -> None:
|
| 56 |
+
"""``lazy_images=False`` (défaut) : les images vivent en base64
|
| 57 |
+
inline dans le HTML, aucun fichier d'asset n'est créé."""
|
| 58 |
+
from picarones.report.generator import ReportGenerator
|
| 59 |
+
|
| 60 |
+
out = tmp_path / "report.html"
|
| 61 |
+
gen = ReportGenerator(demo_benchmark_with_images)
|
| 62 |
+
path = gen.generate(out)
|
| 63 |
+
|
| 64 |
+
assert path.exists()
|
| 65 |
+
html = path.read_text(encoding="utf-8")
|
| 66 |
+
# Rétrocompat : data-URI base64 présent
|
| 67 |
+
assert "data:image" in html or "image/png;base64" in html, (
|
| 68 |
+
"En mode par défaut, le HTML doit contenir des data-URI base64."
|
| 69 |
+
)
|
| 70 |
+
# Pas de dossier d'assets externes
|
| 71 |
+
assert not (tmp_path / "report-assets").exists(), (
|
| 72 |
+
"En mode inline, aucun fichier d'asset ne doit être créé."
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# ---------------------------------------------------------------------------
|
| 77 |
+
# Mode lazy : images externalisées
|
| 78 |
+
# ---------------------------------------------------------------------------
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_lazy_mode_creates_asset_directory(
|
| 82 |
+
demo_benchmark_with_images, tmp_path: Path
|
| 83 |
+
) -> None:
|
| 84 |
+
"""``lazy_images=True`` : ``report-assets/`` est créé à côté du HTML
|
| 85 |
+
et contient des fichiers image."""
|
| 86 |
+
from picarones.report.generator import ReportGenerator
|
| 87 |
+
|
| 88 |
+
out = tmp_path / "report.html"
|
| 89 |
+
gen = ReportGenerator(demo_benchmark_with_images, lazy_images=True)
|
| 90 |
+
path = gen.generate(out)
|
| 91 |
+
|
| 92 |
+
assert path.exists()
|
| 93 |
+
assets_dir = tmp_path / "report-assets"
|
| 94 |
+
assert assets_dir.exists() and assets_dir.is_dir()
|
| 95 |
+
asset_files = list(assets_dir.iterdir())
|
| 96 |
+
assert len(asset_files) >= 1, (
|
| 97 |
+
f"Au moins une image doit être externalisée. "
|
| 98 |
+
f"Trouvé : {asset_files}"
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_lazy_mode_html_references_relative_urls(
|
| 103 |
+
demo_benchmark_with_images, tmp_path: Path
|
| 104 |
+
) -> None:
|
| 105 |
+
"""En mode lazy, le HTML référence les images via URL relative
|
| 106 |
+
``report-assets/...`` plutôt qu'un data-URI."""
|
| 107 |
+
from picarones.report.generator import ReportGenerator
|
| 108 |
+
|
| 109 |
+
out = tmp_path / "report.html"
|
| 110 |
+
gen = ReportGenerator(demo_benchmark_with_images, lazy_images=True)
|
| 111 |
+
path = gen.generate(out)
|
| 112 |
+
|
| 113 |
+
html = path.read_text(encoding="utf-8")
|
| 114 |
+
assert "report-assets/" in html, (
|
| 115 |
+
"Le HTML doit référencer les images via URL relative."
|
| 116 |
+
)
|
| 117 |
+
# ``loading="lazy"`` doit toujours être présent (le template le pose)
|
| 118 |
+
assert 'loading="lazy"' in html
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def test_lazy_mode_significantly_reduces_html_size(
|
| 122 |
+
demo_benchmark_with_images, tmp_path: Path
|
| 123 |
+
) -> None:
|
| 124 |
+
"""Le HTML lazy doit être nettement plus petit que le HTML inline.
|
| 125 |
+
|
| 126 |
+
Sur le corpus démo (3 docs × 200×100 PNG), le ratio doit être
|
| 127 |
+
favorable au lazy. Test peu strict (ratio > 1.05) pour ne pas
|
| 128 |
+
être flaky en fonction du contenu vendor.
|
| 129 |
+
"""
|
| 130 |
+
from picarones.report.generator import ReportGenerator
|
| 131 |
+
|
| 132 |
+
inline_out = tmp_path / "inline.html"
|
| 133 |
+
lazy_out = tmp_path / "lazy.html"
|
| 134 |
+
|
| 135 |
+
ReportGenerator(demo_benchmark_with_images, lazy_images=False).generate(inline_out)
|
| 136 |
+
ReportGenerator(demo_benchmark_with_images, lazy_images=True).generate(lazy_out)
|
| 137 |
+
|
| 138 |
+
inline_size = inline_out.stat().st_size
|
| 139 |
+
lazy_size = lazy_out.stat().st_size
|
| 140 |
+
assert inline_size > lazy_size, (
|
| 141 |
+
f"Le HTML lazy ({lazy_size} B) doit être < HTML inline "
|
| 142 |
+
f"({inline_size} B). Diff : {inline_size - lazy_size} B."
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# ---------------------------------------------------------------------------
|
| 147 |
+
# Robustesse
|
| 148 |
+
# ---------------------------------------------------------------------------
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def test_lazy_mode_with_missing_image_does_not_crash(tmp_path: Path) -> None:
|
| 152 |
+
"""Si l'image source n'existe pas, l'externalisation log un warning
|
| 153 |
+
et continue (rétrocompat avec ``_encode_image_b64`` qui retourne ''
|
| 154 |
+
silencieusement)."""
|
| 155 |
+
from picarones.report.generator import ReportGenerator
|
| 156 |
+
|
| 157 |
+
bench = generate_sample_benchmark(n_docs=2)
|
| 158 |
+
# Pointe vers un chemin inexistant
|
| 159 |
+
for er in bench.engine_reports:
|
| 160 |
+
for dr in er.document_results:
|
| 161 |
+
dr.image_path = "/nonexistent/missing.png"
|
| 162 |
+
|
| 163 |
+
out = tmp_path / "report.html"
|
| 164 |
+
# Ne doit PAS lever
|
| 165 |
+
path = ReportGenerator(bench, lazy_images=True).generate(out)
|
| 166 |
+
assert path.exists()
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def test_safe_filename_generation(tmp_path: Path) -> None:
|
| 170 |
+
"""Les doc_id contenant des caractères non-FS-safe doivent produire
|
| 171 |
+
des noms de fichiers normalisés (pas de path traversal possible)."""
|
| 172 |
+
from PIL import Image
|
| 173 |
+
|
| 174 |
+
from picarones.report.generator import _externalize_images_to_dir
|
| 175 |
+
|
| 176 |
+
src = tmp_path / "src.png"
|
| 177 |
+
Image.new("RGB", (50, 50), color=(0, 0, 0)).save(src)
|
| 178 |
+
|
| 179 |
+
bench = generate_sample_benchmark(n_docs=1)
|
| 180 |
+
bad_id = "../../etc/passwd"
|
| 181 |
+
for er in bench.engine_reports:
|
| 182 |
+
for dr in er.document_results:
|
| 183 |
+
dr.doc_id = bad_id
|
| 184 |
+
dr.image_path = str(src)
|
| 185 |
+
|
| 186 |
+
out_dir = tmp_path / "out"
|
| 187 |
+
out_dir.mkdir()
|
| 188 |
+
mapping = _externalize_images_to_dir(bench, out_dir)
|
| 189 |
+
|
| 190 |
+
# Garde-fou de path traversal : aucun fichier ne doit être créé en
|
| 191 |
+
# dehors de out_dir/report-assets, **et** le chemin résolu de tout
|
| 192 |
+
# fichier d'asset doit rester *à l'intérieur* du dossier d'assets.
|
| 193 |
+
forbidden = out_dir.parent / "etc" / "passwd"
|
| 194 |
+
assert not forbidden.exists(), "Path traversal détecté !"
|
| 195 |
+
assets_dir = (out_dir / "report-assets").resolve()
|
| 196 |
+
if mapping:
|
| 197 |
+
for url in mapping.values():
|
| 198 |
+
assert url.startswith("report-assets/")
|
| 199 |
+
# Le chemin résolu doit être contenu dans assets_dir
|
| 200 |
+
resolved = (out_dir / url).resolve()
|
| 201 |
+
assert str(resolved).startswith(str(assets_dir)), (
|
| 202 |
+
f"Path traversal : {resolved} sort de {assets_dir}"
|
| 203 |
+
)
|
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint A5 — bascule à chaud du mode public (M-13).
|
| 2 |
+
|
| 3 |
+
Le mode public est piloté par la variable d'environnement
|
| 4 |
+
``PICARONES_PUBLIC_MODE``. ``picarones.web.security.is_public_mode()``
|
| 5 |
+
la lit à **chaque appel** plutôt qu'au démarrage, ce qui permet à un
|
| 6 |
+
opérateur de basculer le mode sans redémarrer le serveur.
|
| 7 |
+
|
| 8 |
+
Cette suite vérifie que la bascule à chaud fonctionne :
|
| 9 |
+
|
| 10 |
+
1. Au démarrage en mode dev, ``assert_engines_allowed`` accepte les
|
| 11 |
+
moteurs cloud ; après ``setenv PICARONES_PUBLIC_MODE=1``, le même
|
| 12 |
+
appel les refuse.
|
| 13 |
+
2. Inversement : démarrage public → bascule dev → cloud autorisé.
|
| 14 |
+
3. Aucun cache global ne mémorise l'ancienne valeur.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import pytest
|
| 20 |
+
|
| 21 |
+
from picarones.web.security import (
|
| 22 |
+
assert_engines_allowed,
|
| 23 |
+
assert_llm_provider_allowed,
|
| 24 |
+
is_public_mode,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_public_mode_off_allows_cloud_engines(monkeypatch) -> None:
|
| 29 |
+
"""Mode dev : moteurs cloud autorisés sans réserve."""
|
| 30 |
+
monkeypatch.delenv("PICARONES_PUBLIC_MODE", raising=False)
|
| 31 |
+
assert is_public_mode() is False
|
| 32 |
+
# Ne doit pas lever
|
| 33 |
+
assert_engines_allowed(["mistral_ocr", "google_vision", "azure_doc_intel"])
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_public_mode_on_blocks_cloud_engines(monkeypatch) -> None:
|
| 37 |
+
"""Mode public : moteurs cloud refusés (clés mutualisées côté serveur)."""
|
| 38 |
+
monkeypatch.setenv("PICARONES_PUBLIC_MODE", "1")
|
| 39 |
+
assert is_public_mode() is True
|
| 40 |
+
with pytest.raises(PermissionError):
|
| 41 |
+
assert_engines_allowed(["mistral_ocr"])
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_hot_swap_dev_to_public(monkeypatch) -> None:
|
| 45 |
+
"""Bascule à chaud dev → public. Le même appel passe puis échoue
|
| 46 |
+
sans redémarrage du process."""
|
| 47 |
+
monkeypatch.delenv("PICARONES_PUBLIC_MODE", raising=False)
|
| 48 |
+
# Phase 1 : dev → cloud autorisé
|
| 49 |
+
assert_engines_allowed(["mistral_ocr"]) # ne lève pas
|
| 50 |
+
|
| 51 |
+
# Phase 2 : bascule à chaud
|
| 52 |
+
monkeypatch.setenv("PICARONES_PUBLIC_MODE", "1")
|
| 53 |
+
with pytest.raises(PermissionError):
|
| 54 |
+
assert_engines_allowed(["mistral_ocr"])
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_hot_swap_public_to_dev(monkeypatch) -> None:
|
| 58 |
+
"""Bascule inverse : public → dev. Le même cloud refusé puis accepté."""
|
| 59 |
+
monkeypatch.setenv("PICARONES_PUBLIC_MODE", "1")
|
| 60 |
+
with pytest.raises(PermissionError):
|
| 61 |
+
assert_engines_allowed(["google_vision"])
|
| 62 |
+
|
| 63 |
+
monkeypatch.delenv("PICARONES_PUBLIC_MODE", raising=False)
|
| 64 |
+
assert_engines_allowed(["google_vision"]) # ne lève pas
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_hot_swap_llm_provider_check(monkeypatch) -> None:
|
| 68 |
+
"""``assert_llm_provider_allowed`` doit aussi être sensible à la
|
| 69 |
+
bascule à chaud."""
|
| 70 |
+
monkeypatch.delenv("PICARONES_PUBLIC_MODE", raising=False)
|
| 71 |
+
assert_llm_provider_allowed("openai") # dev : ok
|
| 72 |
+
|
| 73 |
+
monkeypatch.setenv("PICARONES_PUBLIC_MODE", "1")
|
| 74 |
+
with pytest.raises(PermissionError):
|
| 75 |
+
assert_llm_provider_allowed("openai")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_engines_allowed_partial_block(monkeypatch) -> None:
|
| 79 |
+
"""En mode public, si la liste contient cloud + local, l'erreur
|
| 80 |
+
doit identifier précisément quel(s) moteur(s) sont refusés."""
|
| 81 |
+
monkeypatch.setenv("PICARONES_PUBLIC_MODE", "1")
|
| 82 |
+
with pytest.raises(PermissionError) as exc_info:
|
| 83 |
+
assert_engines_allowed(["tesseract", "mistral_ocr", "pero_ocr"])
|
| 84 |
+
msg = str(exc_info.value)
|
| 85 |
+
# Le message doit mentionner le moteur cloud refusé (pour un
|
| 86 |
+
# diagnostic clair côté frontend).
|
| 87 |
+
assert "mistral_ocr" in msg
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_empty_engine_list_passes_in_both_modes(monkeypatch) -> None:
|
| 91 |
+
"""Une liste vide ne doit jamais lever (même en mode public)."""
|
| 92 |
+
monkeypatch.delenv("PICARONES_PUBLIC_MODE", raising=False)
|
| 93 |
+
assert_engines_allowed([])
|
| 94 |
+
|
| 95 |
+
monkeypatch.setenv("PICARONES_PUBLIC_MODE", "1")
|
| 96 |
+
assert_engines_allowed([])
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def test_local_engines_always_allowed(monkeypatch) -> None:
|
| 100 |
+
"""Tesseract / Pero (locaux) ne doivent jamais être bloqués."""
|
| 101 |
+
monkeypatch.setenv("PICARONES_PUBLIC_MODE", "1")
|
| 102 |
+
assert_engines_allowed(["tesseract"])
|
| 103 |
+
assert_engines_allowed(["pero_ocr"])
|
| 104 |
+
assert_engines_allowed(["tesseract", "pero_ocr"])
|
|
@@ -240,7 +240,14 @@ class TestHTRUnitedSearch:
|
|
| 240 |
# TestHTRUnitedImport
|
| 241 |
# ===========================================================================
|
| 242 |
|
|
|
|
| 243 |
class TestHTRUnitedImport:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
|
| 245 |
def test_import_creates_meta_file(self, tmp_path, htr_catalogue):
|
| 246 |
from picarones.extras.importers.htr_united import import_htr_united_corpus
|
|
|
|
| 240 |
# TestHTRUnitedImport
|
| 241 |
# ===========================================================================
|
| 242 |
|
| 243 |
+
@pytest.mark.network
|
| 244 |
class TestHTRUnitedImport:
|
| 245 |
+
"""Tests qui hit GitHub via ``urllib.request.urlopen(timeout=30)``.
|
| 246 |
+
|
| 247 |
+
Marqués ``network`` (Sprint A5) pour être exclus du run local par
|
| 248 |
+
défaut (sandbox sans accès réseau → 4 timeouts de 30s = bloque la
|
| 249 |
+
suite). La CI réseau-friendly les exécute via ``pytest -m network``.
|
| 250 |
+
"""
|
| 251 |
|
| 252 |
def test_import_creates_meta_file(self, tmp_path, htr_catalogue):
|
| 253 |
from picarones.extras.importers.htr_united import import_htr_united_corpus
|
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint A5 — robustesse SQLite face aux écritures concurrentes.
|
| 2 |
+
|
| 3 |
+
Item M-13 de l'audit institutional-readiness-2026-05.
|
| 4 |
+
|
| 5 |
+
``picarones.web.jobs.JobStore`` est l'unique point d'écriture sur la
|
| 6 |
+
BD ``jobs.sqlite`` (mode WAL, thread-safe par ``_conn`` qui ouvre une
|
| 7 |
+
nouvelle connection par appel). Cette suite valide qu'il survit à :
|
| 8 |
+
|
| 9 |
+
1. N threads créant des jobs simultanément (pas de doublon, pas de
|
| 10 |
+
corruption).
|
| 11 |
+
2. M threads mettant à jour le progress du même job (pas de
|
| 12 |
+
``SQLITE_BUSY`` qui remonte au caller).
|
| 13 |
+
3. Set_status concurrent depuis plusieurs threads.
|
| 14 |
+
|
| 15 |
+
Les tests utilisent un fichier SQLite temporaire isolé pour ne pas
|
| 16 |
+
polluer ``jobs.sqlite`` du dev local.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import threading
|
| 22 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
import pytest
|
| 26 |
+
|
| 27 |
+
from picarones.web.jobs import JobStore
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@pytest.fixture
|
| 31 |
+
def fresh_store(tmp_path: Path) -> JobStore:
|
| 32 |
+
db_path = tmp_path / "jobs_test.sqlite"
|
| 33 |
+
store = JobStore(db_path=db_path)
|
| 34 |
+
return store
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
# Création concurrente
|
| 39 |
+
# ---------------------------------------------------------------------------
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_concurrent_create_no_duplicate(fresh_store: JobStore) -> None:
|
| 43 |
+
"""20 threads créent chacun un job → 20 jobs distincts en BD,
|
| 44 |
+
aucun ID dupliqué."""
|
| 45 |
+
n_threads = 20
|
| 46 |
+
|
| 47 |
+
def _create_one(_) -> str:
|
| 48 |
+
return fresh_store.create_job(payload={"thread": "x"})
|
| 49 |
+
|
| 50 |
+
with ThreadPoolExecutor(max_workers=n_threads) as pool:
|
| 51 |
+
ids = list(pool.map(_create_one, range(n_threads)))
|
| 52 |
+
|
| 53 |
+
assert len(ids) == n_threads
|
| 54 |
+
assert len(set(ids)) == n_threads, (
|
| 55 |
+
f"IDs dupliqués détectés : {[x for x in ids if ids.count(x) > 1]}"
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
listed = fresh_store.list_jobs(limit=n_threads + 5)
|
| 59 |
+
assert len(listed) == n_threads
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ---------------------------------------------------------------------------
|
| 63 |
+
# Update concurrent sur le même job
|
| 64 |
+
# ---------------------------------------------------------------------------
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_concurrent_progress_updates_no_busy_error(fresh_store: JobStore) -> None:
|
| 68 |
+
"""50 updates concurrents sur le même job → pas de SQLITE_BUSY,
|
| 69 |
+
le dernier état persiste de manière cohérente."""
|
| 70 |
+
job_id = fresh_store.create_job(payload={})
|
| 71 |
+
|
| 72 |
+
n_updates = 50
|
| 73 |
+
errors: list[BaseException] = []
|
| 74 |
+
|
| 75 |
+
def _update_one(i: int) -> None:
|
| 76 |
+
try:
|
| 77 |
+
fresh_store.update_progress(
|
| 78 |
+
job_id=job_id,
|
| 79 |
+
progress=float(i) / n_updates,
|
| 80 |
+
processed_docs=i,
|
| 81 |
+
)
|
| 82 |
+
except BaseException as exc: # noqa: BLE001 — on capture pour assert
|
| 83 |
+
errors.append(exc)
|
| 84 |
+
|
| 85 |
+
with ThreadPoolExecutor(max_workers=10) as pool:
|
| 86 |
+
list(pool.map(_update_one, range(n_updates)))
|
| 87 |
+
|
| 88 |
+
assert not errors, f"Erreurs durant updates concurrentes : {errors[:3]}"
|
| 89 |
+
|
| 90 |
+
final = fresh_store.get_job(job_id)
|
| 91 |
+
assert final is not None
|
| 92 |
+
# progress doit être un float ∈ [0, 1] cohérent (pas une valeur corrompue)
|
| 93 |
+
assert 0.0 <= float(final.get("progress", 0)) <= 1.0
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# ---------------------------------------------------------------------------
|
| 97 |
+
# Set status concurrent
|
| 98 |
+
# ---------------------------------------------------------------------------
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def test_concurrent_set_status_serializable(fresh_store: JobStore) -> None:
|
| 102 |
+
"""Plusieurs ``set_status`` en parallèle sur le même job ne doivent
|
| 103 |
+
pas corrompre la table ; le dernier statut écrit doit être l'un
|
| 104 |
+
des statuts valides."""
|
| 105 |
+
job_id = fresh_store.create_job(payload={})
|
| 106 |
+
statuses = ["running", "succeeded", "failed", "cancelled"]
|
| 107 |
+
barrier = threading.Barrier(len(statuses))
|
| 108 |
+
|
| 109 |
+
def _set(status: str) -> None:
|
| 110 |
+
barrier.wait(timeout=5) # synchronise le départ pour maximiser la concurrence
|
| 111 |
+
try:
|
| 112 |
+
fresh_store.set_status(job_id, status)
|
| 113 |
+
except Exception:
|
| 114 |
+
pass # un set_status peut échouer s'il y a transition invalide
|
| 115 |
+
|
| 116 |
+
with ThreadPoolExecutor(max_workers=len(statuses)) as pool:
|
| 117 |
+
list(pool.map(_set, statuses))
|
| 118 |
+
|
| 119 |
+
final = fresh_store.get_job(job_id)
|
| 120 |
+
assert final is not None
|
| 121 |
+
assert final["status"] in statuses + ["pending"]
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# ---------------------------------------------------------------------------
|
| 125 |
+
# Reads pendant writes
|
| 126 |
+
# ---------------------------------------------------------------------------
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def test_reads_during_writes_no_locking_error(fresh_store: JobStore) -> None:
|
| 130 |
+
"""Lectures concurrentes pendant écritures → mode WAL doit permettre
|
| 131 |
+
sans bloquer ni lever."""
|
| 132 |
+
n_jobs = 10
|
| 133 |
+
for _ in range(n_jobs):
|
| 134 |
+
fresh_store.create_job(payload={})
|
| 135 |
+
|
| 136 |
+
stop = threading.Event()
|
| 137 |
+
read_errors: list[BaseException] = []
|
| 138 |
+
write_errors: list[BaseException] = []
|
| 139 |
+
|
| 140 |
+
def _writer() -> None:
|
| 141 |
+
try:
|
| 142 |
+
while not stop.is_set():
|
| 143 |
+
fresh_store.create_job(payload={"writer": "x"})
|
| 144 |
+
except BaseException as exc: # noqa: BLE001
|
| 145 |
+
write_errors.append(exc)
|
| 146 |
+
|
| 147 |
+
def _reader() -> None:
|
| 148 |
+
try:
|
| 149 |
+
while not stop.is_set():
|
| 150 |
+
fresh_store.list_jobs(limit=100)
|
| 151 |
+
except BaseException as exc: # noqa: BLE001
|
| 152 |
+
read_errors.append(exc)
|
| 153 |
+
|
| 154 |
+
threads = [
|
| 155 |
+
threading.Thread(target=_writer),
|
| 156 |
+
threading.Thread(target=_writer),
|
| 157 |
+
threading.Thread(target=_reader),
|
| 158 |
+
threading.Thread(target=_reader),
|
| 159 |
+
]
|
| 160 |
+
for t in threads:
|
| 161 |
+
t.start()
|
| 162 |
+
threading.Event().wait(0.5) # 500 ms de charge mixte
|
| 163 |
+
stop.set()
|
| 164 |
+
for t in threads:
|
| 165 |
+
t.join(timeout=2)
|
| 166 |
+
|
| 167 |
+
assert not read_errors, f"Reads ont levé : {read_errors[:2]}"
|
| 168 |
+
assert not write_errors, f"Writes ont levé : {write_errors[:2]}"
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# ---------------------------------------------------------------------------
|
| 172 |
+
# Garde-fous
|
| 173 |
+
# ---------------------------------------------------------------------------
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def test_get_job_unknown_returns_none(fresh_store: JobStore) -> None:
|
| 177 |
+
"""Un job_id inconnu doit retourner ``None``, pas lever."""
|
| 178 |
+
assert fresh_store.get_job("ghost-job-id") is None
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def test_update_progress_unknown_job_does_not_crash(
|
| 182 |
+
fresh_store: JobStore,
|
| 183 |
+
) -> None:
|
| 184 |
+
"""Update sur un job_id inconnu : pas d'effet, pas de crash."""
|
| 185 |
+
fresh_store.update_progress(job_id="ghost", progress=0.5)
|
| 186 |
+
# Aucun job créé en passant
|
| 187 |
+
assert len(fresh_store.list_jobs()) == 0
|