Claude commited on
Commit
12acb53
·
unverified ·
1 Parent(s): 4309925

test(architecture): eliminate subprocess pytest/mypy from tests

Browse files

Quatre tests lançaient pytest ou mypy via subprocess.run :
- tests/docs/test_readme_consistency.py::test_readme_test_count_matches_baseline
- tests/architecture/test_doc_truthfulness.py::TestTestCountSynced
- tests/docs/test_readme_dual_lang.py::test_readme_tables_consistent_with_code
- tests/architecture/test_mypy_domain_strict.py::test_mypy_strict_on_domain_passes

Risques :
- pytest-dans-pytest avec --cov deadlocke sur le lock .coverage (le code
documentait déjà ce risque via -p no:cacheprovider + --no-cov) ;
- subprocess mypy peut skip silencieusement si mypy n'est pas dans le PATH,
faussant l'invariant strict.

Remplacements :
- Le compteur de tests sort de la prose : README/CLAUDE/GOVERNANCE/docs
passent à "5000+ tests" ; gen_readme_tables.py perd collect_test_count,
_replace_test_count et render_test_counts. Le chiffre canonique vit
désormais dans le badge CI.
- test_readme_consistency vérifie maintenant qu'aucun compteur exact
n'a été réintroduit (regex anti-régression).
- test_doc_truthfulness::TestTestCountSynced devient
TestTestCountInProseRemainsApproximate (même esprit, sans subprocess).
- test_readme_dual_lang importe le script directement via importlib et
appelle render_readme(check_only=True).
- test_mypy_domain_strict passe à mypy.api.run ; absence de mypy =
pytest.fail (pas skip silencieux).

Nouveau garde-fou tests/architecture/test_no_subprocess_pytest.py :
empêche structurellement le retour de subprocess pytest/mypy via une
scan AST-ish (retrait des docstrings + commentaires avant match).

Effet mesuré : la suite architecture + docs tombe de 5.89s à 0.78s sur
le sous-ensemble touché (225 passed, 9 skipped, 0 failed sur l'ensemble).

CLAUDE.md CHANGED
@@ -116,15 +116,13 @@ picarones/
116
 
117
  ## État des tests et bugs historiques
118
 
119
- `pytest tests/` → **5150 passed, 16 skipped, 8 deselected, 2 xfailed, 0 failed**
120
- (post-audit code-quality, mai 2026). Les deselected sont les markers
121
- `live` (5 tests d'intégration contre vraie API/binaire) + `network`
122
- (3 tests qui hit le réseau réel), opt-in en local via `pytest -m live`
123
- ou `pytest -m network`. Le compteur ``passed`` est synchronisé
124
- automatiquement par `scripts/gen_readme_tables.py` (CI : job
125
- ``sync-counters`` ; local : `make sync-counters-check`). Le détail
126
- ``skipped``/``xfailed`` peut dériver de ±2 entre éditions et n'est
127
- pas verrouillé en CI.
128
 
129
  NB : utiliser ``python -m pytest tests/`` plutôt que ``pytest tests/``
130
  directement — l'installation via ``uv tool install pytest`` masque
 
116
 
117
  ## État des tests et bugs historiques
118
 
119
+ `pytest tests/` → **5000+ tests collectés, 0 failed** (mai 2026).
120
+ Les markers `live` (tests d'intégration contre vraie API/binaire) et
121
+ `network` (tests qui hit le réseau réel) sont opt-in en local via
122
+ `pytest -m live` ou `pytest -m network`. Le compteur exact dérive
123
+ de ±10 entre OS selon les binaires optionnels installés (tesseract,
124
+ pero-ocr) c'est le badge CI qui porte le chiffre canonique, pas
125
+ la prose de ce fichier.
 
 
126
 
127
  NB : utiliser ``python -m pytest tests/`` plutôt que ``pytest tests/``
128
  directement — l'installation via ``uv tool install pytest`` masque
GOVERNANCE.md CHANGED
@@ -19,7 +19,7 @@
19
 
20
  ### BDFL / Maintainer principal
21
 
22
- À ce stade du projet (mai 2026, ~3 600 tests, 1.x), **Picarones
23
  est maintenu en BDFL** par
24
  [@maribakulj](https://github.com/maribakulj). Toute décision finale
25
  sur les contrats d'API publique, les choix éditoriaux (palette,
 
19
 
20
  ### BDFL / Maintainer principal
21
 
22
+ À ce stade du projet (mai 2026, 5000+ tests, 1.x), **Picarones
23
  est maintenu en BDFL** par
24
  [@maribakulj](https://github.com/maribakulj). Toute décision finale
25
  sur les contrats d'API publique, les choix éditoriaux (palette,
README.md CHANGED
@@ -401,12 +401,14 @@ python -m mypy picarones/domain/ # strict mode (Layer 1)
401
  python -m mypy picarones/ # lax mode (full tree)
402
  ```
403
 
404
- **Test suite**: ~5150 tests, ~3 min on a modern laptop. Coverage
405
- floor at 85% (currently ~87%). The `network` marker excludes tests
406
- requiring live HTTP. A handful of tests depend on optional engines
407
- (`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
408
- those binaries are not installed in the local environment — the CI
409
- matrix runs them in a fully provisioned image.
 
 
410
 
411
  For end-to-end developer guides, see
412
  [`docs/developer/index.md`](docs/developer/index.md) (FR) /
 
401
  python -m mypy picarones/ # lax mode (full tree)
402
  ```
403
 
404
+ **Test suite**: 5000+ tests, ~3 min on a modern laptop (the exact
405
+ count is published by the CI badge it drifts ±1 depending on which
406
+ optional engines are installed on the runner). Coverage floor at 85%
407
+ (currently ~87%). The `network` marker excludes tests requiring live
408
+ HTTP. A handful of tests depend on optional engines (`pero-ocr`,
409
+ `pytesseract`) and are skipped/fail gracefully when those binaries
410
+ are not installed in the local environment — the CI matrix runs them
411
+ in a fully provisioned image.
412
 
413
  For end-to-end developer guides, see
414
  [`docs/developer/index.md`](docs/developer/index.md) (FR) /
docs/developer/index.md CHANGED
@@ -80,9 +80,9 @@ pip install -e ".[dev,web]"
80
  pytest tests/ -q --tb=short
81
  ```
82
 
83
- À la date du Sprint 21 : **1244 tests passent, 2 sont skip** (dépendance
84
- scipy optionnelle). Toute contribution doit conserver le statut "0
85
- failed".
86
 
87
  ## Démo rapide
88
 
 
80
  pytest tests/ -q --tb=short
81
  ```
82
 
83
+ La suite contient **5000+ tests** (le compteur exact dérive selon les
84
+ binaires optionnels installés ; le badge CI fait foi). Toute
85
+ contribution doit conserver le statut "0 failed".
86
 
87
  ## Démo rapide
88
 
scripts/gen_readme_tables.py CHANGED
@@ -1,15 +1,13 @@
1
  """Génère les tableaux Markdown du README depuis le code réel.
2
 
3
- Sprint A13 (item M-22 / M-23 / M-25 / M-26 du plan de remédiation).
4
-
5
- Ce script remplace les listes manuelles qui dérivaient silencieusement
6
  (le bug typique : un nouvel engine ajouté → README pas mis à jour →
7
  ``test_readme_consistency`` casse au prochain CI).
8
 
9
  Trois tableaux sont produits :
10
 
11
- 1. **Engines** : un par fichier ``picarones/engines/*.py`` (hors base /
12
- factory / __init__).
13
  2. **CLI commands** : depuis ``picarones --help``.
14
  3. **API endpoints** : depuis ``app.openapi()["paths"]``.
15
 
@@ -18,6 +16,12 @@ Le script écrit chaque tableau dans le README entre des balises HTML
18
  ``cli`` et ``endpoints``). En CI, un job re-exécute ce script et
19
  échoue si le diff Git est non vide — garantissant l'absence de dérive.
20
 
 
 
 
 
 
 
21
  Usage :
22
 
23
  .. code-block:: bash
@@ -30,26 +34,12 @@ from __future__ import annotations
30
 
31
  import argparse
32
  import re
33
- import subprocess
34
  import sys
35
  from pathlib import Path
36
 
37
  REPO_ROOT = Path(__file__).resolve().parent.parent
38
  README = REPO_ROOT / "README.md"
39
 
40
- #: Fichiers où ``N tests`` / ``N passed`` est mentionné en prose et
41
- #: doit converger vers le compte réel. L'audit doc S60 avait
42
- #: identifié 5 chiffres divergents dans 5 docs (1072 / 1244 / 3354 /
43
- #: ~3600 / ~5030). Liste explicite plutôt qu'un glob — un mainteneur
44
- #: qui ajoute un nouveau doc doit l'inscrire ici consciemment.
45
- TEST_COUNT_FILES: tuple[Path, ...] = (
46
- README,
47
- REPO_ROOT / "CLAUDE.md",
48
- REPO_ROOT / "GOVERNANCE.md",
49
- REPO_ROOT / "docs" / "developer" / "index.md",
50
- REPO_ROOT / "docs" / "developer" / "index.en.md",
51
- )
52
-
53
  # Permet l'invocation du script en subprocess sans avoir besoin
54
  # d'un ``pip install -e .`` préalable (cas CI / test pytest).
55
  if str(REPO_ROOT) not in sys.path:
@@ -174,40 +164,6 @@ def build_endpoints_table() -> str:
174
  return "\n".join(rows)
175
 
176
 
177
- # ---------------------------------------------------------------------------
178
- # Test count
179
- # ---------------------------------------------------------------------------
180
-
181
-
182
- def collect_test_count() -> int | None:
183
- """Lance ``pytest --collect-only`` et extrait le compteur."""
184
- try:
185
- result = subprocess.run(
186
- [
187
- sys.executable,
188
- "-m",
189
- "pytest",
190
- "--collect-only",
191
- "-q",
192
- "--no-cov",
193
- "-p",
194
- "no:cacheprovider",
195
- "tests/",
196
- ],
197
- capture_output=True,
198
- text=True,
199
- cwd=REPO_ROOT,
200
- timeout=60,
201
- )
202
- except subprocess.TimeoutExpired:
203
- return None
204
- for line in reversed(result.stdout.strip().split("\n")):
205
- m = re.search(r"(\d+)\s+tests?\s+collected", line)
206
- if m:
207
- return int(m.group(1))
208
- return None
209
-
210
-
211
  # ---------------------------------------------------------------------------
212
  # Insertion dans le README
213
  # ---------------------------------------------------------------------------
@@ -234,44 +190,14 @@ def _replace_section(text: str, marker: str, content: str) -> str:
234
  return new_text
235
 
236
 
237
- def _replace_test_count(text: str, count: int) -> str:
238
- """Remplace les mentions ``N tests`` ou ``N passed`` qui citent un
239
- nombre dans la fenêtre [count*0.5, count*2]. Garde la formulation
240
- exacte (espace, ponctuation) intacte.
241
-
242
- Le count est **arrondi à la cinquantaine inférieure** pour rendre
243
- le résultat OS-déterministe : selon les binaires système (tesseract,
244
- pero-ocr) installés sur le runner, certains modules de test sont
245
- skipés au niveau ``pytest.skip(allow_module_level=True)`` — ce qui
246
- soustrait le fichier entier de la collection. Exemple observé en
247
- S8.7 : Linux CI (avec tesseract) collecte 4510 tests, dev local
248
- (sans tesseract) en collecte 4509. Avec un floor à 10 ces deux
249
- valeurs divergent (4510 vs 4500) ; avec un floor à 50, elles
250
- convergent toutes deux vers 4500.
251
-
252
- Note : utilise ``(count // 50) * 50`` plutôt que
253
- ``round(count, -1)``. Le ``round()`` Python applique le
254
- "banker's rounding" (round half to even) qui n'est pas
255
- monotone. Le floor à 50 garde la propriété de monotonie (un
256
- ajout de tests ne fait jamais reculer le compteur) tout en
257
- absorbant les écarts de ±49 tests entre environnements.
258
- """
259
- rounded_count = (count // 50) * 50
260
-
261
- def _sub(match: re.Match) -> str:
262
- cited = int(match.group(1))
263
- # Ne touche pas si le nombre cité est complètement hors plage —
264
- # c'est probablement une autre référence (un chiffre dans une
265
- # phrase qui parle d'autre chose).
266
- if cited < count * 0.5 or cited > count * 2:
267
- return match.group(0)
268
- return match.group(0).replace(str(cited), str(rounded_count))
269
-
270
- return re.sub(r"(\d{3,5})\s+(?:tests|passed)\b", _sub, text)
271
-
272
-
273
  def render_readme(check_only: bool = False) -> int:
274
- """Met à jour les sections générées du README. Retourne 0 ou 1."""
 
 
 
 
 
 
275
  if not README.exists():
276
  sys.stderr.write(f"README absent : {README}\n")
277
  return 1
@@ -282,10 +208,6 @@ def render_readme(check_only: bool = False) -> int:
282
  text = _replace_section(text, "cli", build_cli_table())
283
  text = _replace_section(text, "endpoints", build_endpoints_table())
284
 
285
- count = collect_test_count()
286
- if count is not None:
287
- text = _replace_test_count(text, count)
288
-
289
  if check_only:
290
  if text != original:
291
  sys.stderr.write(
@@ -304,55 +226,6 @@ def render_readme(check_only: bool = False) -> int:
304
  return 0
305
 
306
 
307
- def render_test_counts(check_only: bool = False) -> int:
308
- """Synchronise le compte de tests dans tous les ``TEST_COUNT_FILES``.
309
-
310
- Audit doc S60 : 5 chiffres divergents (1072 / 1244 / 3354 /
311
- ~3600 / ~5030) selon les docs. Cette fonction lit le compte
312
- réel via ``pytest --collect-only`` et l'injecte dans chaque
313
- fichier de la liste.
314
-
315
- Returns
316
- -------
317
- int
318
- 0 si tout est synchronisé, 1 si divergence (en mode check)
319
- ou erreur d'écriture.
320
- """
321
- count = collect_test_count()
322
- if count is None:
323
- # ``pytest --collect-only`` indisponible (env CI minimal,
324
- # virtualenv dégradé). On ne casse pas le build pour ça.
325
- sys.stderr.write(
326
- "[gen_readme_tables] collect_test_count indisponible — "
327
- "skip mise à jour des compteurs de tests.\n",
328
- )
329
- return 0
330
-
331
- divergent = False
332
- for path in TEST_COUNT_FILES:
333
- if not path.exists():
334
- continue
335
- original = path.read_text(encoding="utf-8")
336
- updated = _replace_test_count(original, count)
337
- if updated == original:
338
- continue
339
- divergent = True
340
- if check_only:
341
- sys.stderr.write(
342
- f"[gen_readme_tables] {path.relative_to(REPO_ROOT)} "
343
- "diverge du compteur de tests réel.\n",
344
- )
345
- else:
346
- path.write_text(updated, encoding="utf-8")
347
- print(
348
- f"[gen_readme_tables] {path.relative_to(REPO_ROOT)} "
349
- "test count mis à jour.",
350
- )
351
- if check_only and divergent:
352
- return 1
353
- return 0
354
-
355
-
356
  def main() -> int:
357
  parser = argparse.ArgumentParser(description=__doc__)
358
  parser.add_argument(
@@ -361,9 +234,7 @@ def main() -> int:
361
  help="N'écrit rien ; sort 1 si le README diverge du code généré.",
362
  )
363
  args = parser.parse_args()
364
- rc_readme = render_readme(check_only=args.check)
365
- rc_counts = render_test_counts(check_only=args.check)
366
- return rc_readme or rc_counts
367
 
368
 
369
  if __name__ == "__main__":
 
1
  """Génère les tableaux Markdown du README depuis le code réel.
2
 
3
+ Le script remplace les listes manuelles qui dérivaient silencieusement
 
 
4
  (le bug typique : un nouvel engine ajouté → README pas mis à jour →
5
  ``test_readme_consistency`` casse au prochain CI).
6
 
7
  Trois tableaux sont produits :
8
 
9
+ 1. **Engines** : un par adapter sous ``picarones/adapters/ocr/`` (hors
10
+ base / factory / __init__).
11
  2. **CLI commands** : depuis ``picarones --help``.
12
  3. **API endpoints** : depuis ``app.openapi()["paths"]``.
13
 
 
16
  ``cli`` et ``endpoints``). En CI, un job re-exécute ce script et
17
  échoue si le diff Git est non vide — garantissant l'absence de dérive.
18
 
19
+ Le compteur de tests n'est PAS géré ici : il dérivait selon l'OS et
20
+ les binaires système installés (4509 vs 4510 selon que tesseract est
21
+ présent ou non), donc on l'a sorti de la prose. La règle actuelle :
22
+ le README dit ``5000+ tests`` (formulation non quantifiée) et le
23
+ chiffre exact vit dans le badge CI / Codecov.
24
+
25
  Usage :
26
 
27
  .. code-block:: bash
 
34
 
35
  import argparse
36
  import re
 
37
  import sys
38
  from pathlib import Path
39
 
40
  REPO_ROOT = Path(__file__).resolve().parent.parent
41
  README = REPO_ROOT / "README.md"
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  # Permet l'invocation du script en subprocess sans avoir besoin
44
  # d'un ``pip install -e .`` préalable (cas CI / test pytest).
45
  if str(REPO_ROOT) not in sys.path:
 
164
  return "\n".join(rows)
165
 
166
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  # ---------------------------------------------------------------------------
168
  # Insertion dans le README
169
  # ---------------------------------------------------------------------------
 
190
  return new_text
191
 
192
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  def render_readme(check_only: bool = False) -> int:
194
+ """Met à jour les sections générées du README. Retourne 0 ou 1.
195
+
196
+ Le compteur de tests n'est plus injecté en prose : il dérivait
197
+ selon l'OS et les binaires système installés, et la stratégie
198
+ actuelle est ``5000+ tests`` (formulation non quantifiée) avec le
199
+ chiffre exact porté par le badge CI.
200
+ """
201
  if not README.exists():
202
  sys.stderr.write(f"README absent : {README}\n")
203
  return 1
 
208
  text = _replace_section(text, "cli", build_cli_table())
209
  text = _replace_section(text, "endpoints", build_endpoints_table())
210
 
 
 
 
 
211
  if check_only:
212
  if text != original:
213
  sys.stderr.write(
 
226
  return 0
227
 
228
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  def main() -> int:
230
  parser = argparse.ArgumentParser(description=__doc__)
231
  parser.add_argument(
 
234
  help="N'écrit rien ; sort 1 si le README diverge du code généré.",
235
  )
236
  args = parser.parse_args()
237
+ return render_readme(check_only=args.check)
 
 
238
 
239
 
240
  if __name__ == "__main__":
tests/architecture/test_doc_truthfulness.py CHANGED
@@ -22,8 +22,6 @@ from __future__ import annotations
22
 
23
  from pathlib import Path
24
 
25
- import pytest
26
-
27
  REPO_ROOT = Path(__file__).resolve().parents[2]
28
  ARCHITECTURE_MD = REPO_ROOT / "docs" / "explanation" / "architecture.md"
29
  CLAUDE_MD = REPO_ROOT / "CLAUDE.md"
@@ -135,90 +133,48 @@ class TestArchitectureManifestoTruthful:
135
 
136
 
137
  # ──────────────────────────────────────────────────────────────────────
138
- # 2. Compteurs de tests synchronisés
139
  # ──────────────────────────────────────────────────────────────────────
140
-
141
-
142
- class TestTestCountSynced:
143
- """Le compteur ``N tests passed`` cité dans CLAUDE.md / README.md
144
- doit rester proche du compte réel.
145
-
146
- Le script ``scripts/gen_readme_tables.py`` est censé maintenir la
147
- cohérence ; ce test attrape les cas où il n'a pas tourné.
148
-
149
- Tolérance : ``±5`` tests autour du compte réel (un commit peut
150
- introduire 1-3 nouveaux tests sans qu'on regenère immédiatement
151
- la doc — au-delà, c'est de la dérive).
152
- """
153
-
154
- @pytest.fixture
155
- def real_test_count(self) -> int:
156
- """Count réel des tests collectés par pytest (hors deselected)."""
157
- import subprocess
158
- import sys
159
-
160
- result = subprocess.run(
161
- [
162
- sys.executable, "-m", "pytest",
163
- "--collect-only", "-q", "--no-cov",
164
- "-p", "no:cacheprovider",
165
- str(REPO_ROOT / "tests"),
166
- ],
167
- capture_output=True, text=True, cwd=REPO_ROOT, timeout=60,
168
- )
169
- # La dernière ligne pertinente : « X tests collected »
170
- import re
171
- for line in reversed(result.stdout.strip().split("\n")):
172
- m = re.search(r"(\d+)\s+tests?\s+collected", line)
173
- if m:
174
- return int(m.group(1))
175
- pytest.fail(
176
- f"Impossible d'extraire le compte de pytest --collect-only.\n"
177
- f"stdout: {result.stdout[-500:]}\nstderr: {result.stderr[-200:]}"
178
  )
179
 
180
- def _extract_count(self, text: str) -> int | None:
181
- """Cherche un nombre près du mot ``passed`` dans ``text``."""
182
- import re
183
- # Matche « 4189 passed » ou « ~4150 tests » ou « 4150 tests passed ».
184
- for pattern in (
185
- r"\*\*(\d{3,5})\s+passed",
186
- r"(\d{3,5})\s+passed",
187
- r"~?(\d{3,5})\s+tests",
188
- ):
189
- m = re.search(pattern, text)
190
- if m:
191
- return int(m.group(1))
192
- return None
193
-
194
- def test_claude_md_count_close_to_reality(
195
- self, real_test_count: int,
196
- ) -> None:
197
  text = CLAUDE_MD.read_text(encoding="utf-8")
198
- claimed = self._extract_count(text)
199
- assert claimed is not None, (
200
- "CLAUDE.md ne contient aucun compteur de tests (``N passed``)."
201
- )
202
- delta = abs(claimed - real_test_count)
203
- assert delta <= 50, (
204
- f"CLAUDE.md annonce {claimed} tests, réalité = "
205
- f"{real_test_count} (écart = {delta}). Tolérance ±50.\n"
206
- f"Lancer ``python scripts/gen_readme_tables.py`` puis "
207
- f"committer."
208
- )
209
-
210
- def test_readme_md_count_close_to_reality(
211
- self, real_test_count: int,
212
- ) -> None:
213
- text = README_MD.read_text(encoding="utf-8")
214
- claimed = self._extract_count(text)
215
- assert claimed is not None, (
216
- "README.md ne contient aucun compteur de tests."
217
- )
218
- delta = abs(claimed - real_test_count)
219
- assert delta <= 50, (
220
- f"README.md annonce {claimed} tests, réalité = "
221
- f"{real_test_count} (écart = {delta})."
222
  )
223
 
224
 
 
22
 
23
  from pathlib import Path
24
 
 
 
25
  REPO_ROOT = Path(__file__).resolve().parents[2]
26
  ARCHITECTURE_MD = REPO_ROOT / "docs" / "explanation" / "architecture.md"
27
  CLAUDE_MD = REPO_ROOT / "CLAUDE.md"
 
133
 
134
 
135
  # ──────────────────────────────────────────────────────────────────────
136
+ # 2. Compteurs de tests — pas de chiffre exact en prose
137
  # ──────────────────────────────────────────────────────────────────────
138
+ #
139
+ # Historique : ce module comparait ``N tests passed`` cité dans
140
+ # CLAUDE.md / README.md au compte réel via
141
+ # ``subprocess.run([..., "pytest", "--collect-only", ...])``. Trois
142
+ # problèmes : (a) pytest-dans-pytest avec ``--cov`` deadlocke sur
143
+ # ``.coverage`` ; (b) le compteur réel dérive de ±1 entre OS selon
144
+ # les binaires optionnels installés ; (c) un test qui rate à cause
145
+ # d'un compteur en prose est purement narratif.
146
+ #
147
+ # Stratégie actuelle : la prose dit ``5000+ tests`` (sans nombre
148
+ # exact), le chiffre canonique vit dans le badge CI. Ces tests
149
+ # verrouillent l'absence de réintroduction d'un compteur exact.
150
+
151
+ import re
152
+
153
+
154
+ class TestTestCountInProseRemainsApproximate:
155
+ """README et CLAUDE.md ne doivent plus citer de compteur de tests
156
+ exact. La formulation canonique est ``N+ tests`` / ``N+ passed``
157
+ (avec le ``+`` qui marque l'approximation)."""
158
+
159
+ _FORBIDDEN = re.compile(
160
+ r"(?<!\+)\b(\d{4,5})\s+(?:tests|passed)\b",
161
+ re.IGNORECASE,
162
+ )
163
+
164
+ def test_readme_uses_approximate_formulation(self) -> None:
165
+ text = README_MD.read_text(encoding="utf-8")
166
+ offenders = self._FORBIDDEN.findall(text)
167
+ assert not offenders, (
168
+ f"README.md cite des compteurs exacts : {offenders}. "
169
+ "Utiliser ``N+ tests`` (ex. ``5000+ tests``)."
 
 
 
 
 
 
170
  )
171
 
172
+ def test_claude_md_uses_approximate_formulation(self) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  text = CLAUDE_MD.read_text(encoding="utf-8")
174
+ offenders = self._FORBIDDEN.findall(text)
175
+ assert not offenders, (
176
+ f"CLAUDE.md cite des compteurs exacts : {offenders}. "
177
+ "Utiliser ``N+ tests`` (ex. ``5000+ tests``)."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  )
179
 
180
 
tests/architecture/test_mypy_domain_strict.py CHANGED
@@ -17,8 +17,7 @@ Après S3.6 :
17
 
18
  from __future__ import annotations
19
 
20
- import subprocess
21
- import sys
22
  from pathlib import Path
23
 
24
  import pytest
@@ -27,20 +26,37 @@ REPO_ROOT = Path(__file__).resolve().parents[2]
27
 
28
 
29
  def test_mypy_strict_on_domain_passes() -> None:
30
- """``mypy picarones/domain/`` doit retourner 0 erreur."""
31
- result = subprocess.run(
32
- [sys.executable, "-m", "mypy", "picarones/domain/"],
33
- capture_output=True,
34
- text=True,
35
- cwd=REPO_ROOT,
36
- timeout=120,
37
- )
38
- if result.returncode != 0:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  pytest.fail(
40
  f"mypy strict sur ``picarones/domain`` échoue.\n"
41
- f"return code: {result.returncode}\n"
42
- f"stdout:\n{result.stdout}\n"
43
- f"stderr:\n{result.stderr[-500:]}"
44
  )
45
 
46
 
 
17
 
18
  from __future__ import annotations
19
 
20
+ import os
 
21
  from pathlib import Path
22
 
23
  import pytest
 
26
 
27
 
28
  def test_mypy_strict_on_domain_passes() -> None:
29
+ """``mypy picarones/domain/`` doit retourner 0 erreur.
30
+
31
+ Utilise l'API programmatique ``mypy.api.run`` plutôt qu'un
32
+ ``subprocess.run`` : (a) plus rapide (pas de fork), (b) pas de
33
+ parsing de stdout, (c) si mypy est absent, l'erreur est explicite
34
+ (``ImportError``) au lieu d'un échec silencieux du subprocess.
35
+ """
36
+ try:
37
+ from mypy import api as mypy_api
38
+ except ImportError as e:
39
+ pytest.fail(
40
+ f"mypy n'est pas installé — ce test ne peut pas être skippé "
41
+ f"en silence car il verrouille un invariant strict.\n"
42
+ f"Installer via ``pip install -e .[dev]``. ImportError: {e}"
43
+ )
44
+
45
+ # Travailler depuis REPO_ROOT pour que pyproject.toml soit
46
+ # découvert correctement par mypy.
47
+ prev_cwd = Path.cwd()
48
+ try:
49
+ os.chdir(REPO_ROOT)
50
+ stdout, stderr, exit_code = mypy_api.run(["picarones/domain/"])
51
+ finally:
52
+ os.chdir(prev_cwd)
53
+
54
+ if exit_code != 0:
55
  pytest.fail(
56
  f"mypy strict sur ``picarones/domain`` échoue.\n"
57
+ f"return code: {exit_code}\n"
58
+ f"stdout:\n{stdout}\n"
59
+ f"stderr:\n{stderr[-500:]}"
60
  )
61
 
62
 
tests/architecture/test_no_subprocess_pytest.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Garde-fou : aucun test ne doit lancer pytest ou mypy via subprocess.
2
+
3
+ Pourquoi ce test existe
4
+ -----------------------
5
+
6
+ Lancer ``subprocess.run([sys.executable, "-m", "pytest", ...])``
7
+ depuis un test pytest cause un deadlock potentiel sur le lock du
8
+ fichier ``.coverage`` quand le test parent tourne lui-même sous
9
+ ``pytest --cov`` (cas standard de la CI).
10
+
11
+ L'historique du repo contient des commentaires comme « ``-p
12
+ no:cacheprovider`` + ``--no-cov`` évitent les deadlocks de récursion »
13
+ — c'est précisément ce que ce test prévient en bloquant la cause
14
+ plutôt qu'en mitigeant les symptômes.
15
+
16
+ Les outils en ligne de commande (``mypy``, ``pytest``, ``ruff``,
17
+ ``bandit``) exposent tous une API programmatique :
18
+
19
+ - ``from mypy import api ; api.run([...])``
20
+ - ``import pytest ; pytest.main([...])`` (rare, généralement
21
+ remplaçable par une assertion directe sur ``collect_only``)
22
+ - ``import ruff`` non exposé, mais le besoin est rare en test
23
+
24
+ Périmètre
25
+ ---------
26
+
27
+ On scanne tous les fichiers ``tests/**/*.py`` à la recherche de
28
+ patterns qui correspondent à un appel subprocess vers ces outils.
29
+ On accepte :
30
+
31
+ - les ``subprocess`` qui lancent des binaires système (``tesseract``,
32
+ ``docker``, etc.) ;
33
+ - les ``subprocess`` qui lancent un script Python du repo
34
+ (``scripts/...``) tant que ce script ne ré-invoque pas pytest.
35
+
36
+ On refuse :
37
+
38
+ - ``subprocess.run([..., "pytest", ...])``
39
+ - ``subprocess.run([sys.executable, "-m", "pytest", ...])``
40
+ - ``pytest.main(...)`` (récursion potentielle)
41
+ - ``subprocess.run([..., "mypy", ...])`` (utiliser ``mypy.api.run``)
42
+
43
+ Exceptions
44
+ ----------
45
+
46
+ Aucune n'est tolérée. Si un cas vraiment indispensable apparaît,
47
+ l'ajouter ici **avec justification** plutôt que de le laisser
48
+ fragiliser une partie du repo.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import re
54
+ from pathlib import Path
55
+
56
+ REPO_ROOT = Path(__file__).resolve().parents[2]
57
+ TESTS_DIR = REPO_ROOT / "tests"
58
+
59
+ #: Fichiers tolérés explicitement. Le scanner lui-même contient les
60
+ #: patterns qu'il interdit (sinon il ne pourrait pas les chercher) ;
61
+ #: il s'auto-exclut. Toute autre addition demande une justification
62
+ #: en revue.
63
+ ALLOWLIST: frozenset[str] = frozenset({
64
+ "tests/architecture/test_no_subprocess_pytest.py",
65
+ })
66
+
67
+ #: Patterns refusés. L'ordre importe : on retient le premier match
68
+ #: pour un message d'erreur clair.
69
+ _FORBIDDEN_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
70
+ (
71
+ "subprocess.run([..., 'pytest', ...])",
72
+ re.compile(
73
+ r'subprocess\.(?:run|Popen|check_call|check_output|call)'
74
+ r'\s*\([^)]*["\']pytest["\']',
75
+ re.DOTALL,
76
+ ),
77
+ ),
78
+ (
79
+ "subprocess.run([sys.executable, '-m', 'pytest', ...])",
80
+ re.compile(
81
+ r'subprocess\.(?:run|Popen|check_call|check_output|call)'
82
+ r'\s*\(\s*\[[^\]]*sys\.executable[^\]]*["\']pytest["\']',
83
+ re.DOTALL,
84
+ ),
85
+ ),
86
+ (
87
+ "subprocess.run([..., 'mypy', ...])",
88
+ re.compile(
89
+ r'subprocess\.(?:run|Popen|check_call|check_output|call)'
90
+ r'\s*\([^)]*["\']mypy["\']',
91
+ re.DOTALL,
92
+ ),
93
+ ),
94
+ (
95
+ "pytest.main(...)",
96
+ re.compile(r'\bpytest\.main\s*\('),
97
+ ),
98
+ )
99
+
100
+
101
+ def _strip_comments_and_docstrings(text: str) -> str:
102
+ """Retire les commentaires Python et les docstrings triple-quoted
103
+ pour éviter les faux positifs sur les fichiers qui *décrivent* le
104
+ motif interdit en prose (cas typique d'un commentaire ``# Historique :
105
+ ce test lançait subprocess.run(..., 'pytest', ...) ...``).
106
+
107
+ L'heuristique est volontairement simple — pas de parser Python
108
+ complet — parce qu'on ne veut pas matcher un motif qui apparaît
109
+ uniquement dans du texte non exécutable."""
110
+ # Triple-quoted strings (docstrings et chaînes multi-lignes)
111
+ text = re.sub(r'"""[\s\S]*?"""', "", text)
112
+ text = re.sub(r"'''[\s\S]*?'''", "", text)
113
+ # Commentaires single-line : tout ce qui suit un ``#`` sur la ligne.
114
+ # On ignore le cas pathologique d'un ``#`` dans une chaîne car le
115
+ # fichier scanné est du code de test (pas de littérature
116
+ # défensive nécessaire à ce stade).
117
+ text = re.sub(r"#[^\n]*", "", text)
118
+ return text
119
+
120
+
121
+ def _scan_file(path: Path) -> list[str]:
122
+ """Retourne la liste des patterns interdits trouvés dans ``path``."""
123
+ text = _strip_comments_and_docstrings(
124
+ path.read_text(encoding="utf-8")
125
+ )
126
+ return [
127
+ label
128
+ for label, pattern in _FORBIDDEN_PATTERNS
129
+ if pattern.search(text)
130
+ ]
131
+
132
+
133
+ def test_no_test_invokes_pytest_or_mypy_via_subprocess() -> None:
134
+ offenders: list[str] = []
135
+ for path in sorted(TESTS_DIR.rglob("*.py")):
136
+ rel = path.relative_to(REPO_ROOT).as_posix()
137
+ if rel in ALLOWLIST:
138
+ continue
139
+ found = _scan_file(path)
140
+ if found:
141
+ offenders.append(f"{rel} : {', '.join(found)}")
142
+
143
+ assert not offenders, (
144
+ "Tests qui invoquent pytest/mypy en subprocess (risque de "
145
+ "deadlock pytest-dans-pytest et / ou de skip silencieux) :\n "
146
+ + "\n ".join(offenders)
147
+ + "\n\n→ Utiliser l'API programmatique :\n"
148
+ " from mypy import api ; stdout, stderr, rc = api.run([...])\n"
149
+ " # ou supprimer le test s'il duplique un check existant"
150
+ )
tests/docs/test_readme_consistency.py CHANGED
@@ -33,7 +33,6 @@ PR que le README), insérer un commentaire HTML
33
  from __future__ import annotations
34
 
35
  import re
36
- import subprocess
37
  from pathlib import Path
38
 
39
  import pytest
@@ -51,10 +50,6 @@ ENGINES_DIR = REPO_ROOT / "picarones" / "adapters" / "ocr"
51
  #: ``<!-- doc-check: skip-engine -->``, ``skip-cli``, ``skip-endpoint``.
52
  SKIP_PATTERN = re.compile(r"<!--\s*doc-check:\s*skip-([a-z]+)\s*-->")
53
 
54
- #: Tolérance sur le compteur de tests (les PR en cours peuvent ajouter
55
- #: ou retirer 5 % avant que le README soit mis à jour).
56
- TEST_COUNT_TOLERANCE_RATIO = 0.05
57
-
58
  #: Préfixes de "moteurs" du tableau qui ne sont *pas* des moteurs OCR
59
  #: (ce sont des LLMs/VLMs utilisés via les pipelines). Ils sont
60
  #: tolérés en attendant la refonte A13 qui scindera le tableau.
@@ -328,62 +323,41 @@ def test_listed_endpoints_exist() -> None:
328
 
329
 
330
  # ---------------------------------------------------------------------------
331
- # 4. Compteur de tests (M-19, §9.3)
332
  # ---------------------------------------------------------------------------
333
-
334
-
335
- def _collected_test_count() -> int:
336
- """Retourne le nombre exact de tests collectés par pytest."""
337
- # Sprint A5 : ``-p no:cacheprovider`` + ``--no-cov`` évitent les
338
- # deadlocks de récursion quand le test parent tourne lui-même sous
339
- # ``pytest --cov`` (lock du fichier .coverage).
340
- result = subprocess.run(
341
- [
342
- "python", "-m", "pytest",
343
- "--collect-only", "-q",
344
- "-p", "no:cacheprovider",
345
- "--no-cov",
346
- "tests/",
347
- ],
348
- capture_output=True,
349
- text=True,
350
- cwd=REPO_ROOT,
351
- timeout=60,
352
- )
353
- # La dernière ligne non vide ressemble à "3419 tests collected in 3.32s"
354
- for line in reversed(result.stdout.strip().split("\n")):
355
- m = re.search(r"(\d+)\s+tests?\s+collected", line)
356
- if m:
357
- return int(m.group(1))
358
- raise RuntimeError(
359
- f"Impossible d'extraire le compteur depuis pytest --collect-only.\n"
360
- f"stdout: {result.stdout[-500:]}"
361
- )
362
-
363
-
364
- def test_readme_test_count_matches_baseline() -> None:
365
- """Les phrases « N tests » ou « N passed » dans le README doivent
366
- correspondre au compteur réel de pytest, à ``TEST_COUNT_TOLERANCE_RATIO``
367
- près (5 % par défaut)."""
368
  text = _read_readme()
369
- real = _collected_test_count()
370
-
371
- # Cherche les motifs comme "1242 tests" ou "1242 passed"
372
- cited_counts: list[int] = []
373
- for m in re.finditer(r"(\d{3,5})\s+(?:tests|passed)\b", text, re.IGNORECASE):
374
- cited_counts.append(int(m.group(1)))
375
-
376
- if not cited_counts:
377
- pytest.skip("Aucun compteur de tests cité dans le README")
378
 
379
- tolerance = max(1, int(real * TEST_COUNT_TOLERANCE_RATIO))
380
- out_of_tolerance = [
381
- c for c in cited_counts if abs(c - real) > tolerance
382
- ]
383
- assert not out_of_tolerance, (
384
- f"Le README cite des compteurs de tests divergents du baseline "
385
- f"réel ({real}, tolérance ±{tolerance}) : {out_of_tolerance}. "
386
- f"Mettre à jour le README ou tolérer via skip-marker."
 
 
 
 
 
387
  )
388
 
389
 
 
33
  from __future__ import annotations
34
 
35
  import re
 
36
  from pathlib import Path
37
 
38
  import pytest
 
50
  #: ``<!-- doc-check: skip-engine -->``, ``skip-cli``, ``skip-endpoint``.
51
  SKIP_PATTERN = re.compile(r"<!--\s*doc-check:\s*skip-([a-z]+)\s*-->")
52
 
 
 
 
 
53
  #: Préfixes de "moteurs" du tableau qui ne sont *pas* des moteurs OCR
54
  #: (ce sont des LLMs/VLMs utilisés via les pipelines). Ils sont
55
  #: tolérés en attendant la refonte A13 qui scindera le tableau.
 
323
 
324
 
325
  # ---------------------------------------------------------------------------
326
+ # 4. Compteur de tests le README ne pin plus un nombre exact
327
  # ---------------------------------------------------------------------------
328
+ #
329
+ # Historique : ce test lançait ``subprocess.run([..., "pytest",
330
+ # "--collect-only", ...])`` pour comparer le compteur cité au nombre
331
+ # réel. Pytest-dans-pytest avec ``--cov`` cause un deadlock sur le
332
+ # lock ``.coverage`` (le commentaire ``-p no:cacheprovider`` + ``--no-cov``
333
+ # documente déjà ce risque). La stratégie actuelle élimine la classe
334
+ # d'erreur : le README dit ``5000+ tests``, sans nombre figé, et le
335
+ # chiffre exact vit dans le badge CI.
336
+ #
337
+ # Ce test ne fait plus que vérifier qu'aucun compteur exact n'a été
338
+ # réintroduit en prose.
339
+
340
+
341
+ def test_readme_does_not_pin_exact_test_count() -> None:
342
+ """Le README ne doit plus citer un nombre exact (``5150 tests``,
343
+ ``5159 passed``, etc.). La formulation canonique est ``N+ tests``
344
+ (ex. ``5000+ tests``) pour absorber la dérive OS-dépendante du
345
+ compteur (4509 vs 4510 selon que tesseract est installé)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  text = _read_readme()
 
 
 
 
 
 
 
 
 
347
 
348
+ # On accepte ``5000+ tests``, ``5000+ passed`` (avec ou sans
349
+ # caractères de mise en forme markdown autour). On refuse
350
+ # ``5150 tests``, ``~5150 tests``, ``5150 passed``.
351
+ forbidden_pattern = re.compile(
352
+ r"(?<!\+)\b(\d{4,5})\s+(?:tests|passed)\b",
353
+ re.IGNORECASE,
354
+ )
355
+ offenders = forbidden_pattern.findall(text)
356
+ assert not offenders, (
357
+ f"README cite des compteurs de tests exacts : {offenders}. "
358
+ "Reformuler en ``N+ tests`` (ex. ``5000+ tests``) — le chiffre "
359
+ "exact dérive selon l'OS / les binaires installés et vit dans "
360
+ "le badge CI."
361
  )
362
 
363
 
tests/docs/test_readme_dual_lang.py CHANGED
@@ -16,18 +16,29 @@ Ces tests valident :
16
 
17
  from __future__ import annotations
18
 
 
19
  import re
20
- import subprocess
21
- import sys
22
  from pathlib import Path
23
 
24
- import pytest
25
-
26
  REPO_ROOT = Path(__file__).resolve().parents[2]
27
  README = REPO_ROOT / "README.md"
28
  GEN_SCRIPT = REPO_ROOT / "scripts" / "gen_readme_tables.py"
29
 
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  def _read_readme() -> str:
32
  return README.read_text(encoding="utf-8")
33
 
@@ -130,30 +141,20 @@ def test_gen_readme_tables_script_exists() -> None:
130
  )
131
 
132
 
133
- @pytest.mark.skipif(
134
- sys.platform.startswith("win"),
135
- reason=(
136
- "gen_readme_tables.py compare le compte de tests collectés ; "
137
- "le compte diverge entre OS (tests skip différemment selon "
138
- "Windows / Linux / macOS). Le README est généré et committé "
139
- "depuis Linux ; ce test n'est pertinent que sur le même OS."
140
- ),
141
- )
142
  def test_readme_tables_consistent_with_code() -> None:
143
- """``python scripts/gen_readme_tables.py --check`` doit retourner 0
144
- (le README est synchronisé avec le code)."""
145
- result = subprocess.run(
146
- [sys.executable, str(GEN_SCRIPT), "--check"],
147
- capture_output=True,
148
- text=True,
149
- cwd=REPO_ROOT,
150
- timeout=120,
151
- )
152
- assert result.returncode == 0, (
153
- "Le README diverge du contenu généré par scripts/gen_readme_tables.py.\n"
154
- f"stdout: {result.stdout[-500:]}\n"
155
- f"stderr: {result.stderr[-500:]}\n"
156
- "Lancer ``python scripts/gen_readme_tables.py`` puis committer."
157
  )
158
 
159
 
 
16
 
17
  from __future__ import annotations
18
 
19
+ import importlib.util
20
  import re
 
 
21
  from pathlib import Path
22
 
 
 
23
  REPO_ROOT = Path(__file__).resolve().parents[2]
24
  README = REPO_ROOT / "README.md"
25
  GEN_SCRIPT = REPO_ROOT / "scripts" / "gen_readme_tables.py"
26
 
27
 
28
+ def _import_gen_script():
29
+ """Importe ``scripts/gen_readme_tables.py`` en tant que module,
30
+ sans subprocess. Le script lui-même ne lance plus rien (le
31
+ compteur de tests n'est plus injecté en prose), donc l'appel
32
+ direct à ``render_readme(check_only=True)`` est sûr et rapide."""
33
+ spec = importlib.util.spec_from_file_location(
34
+ "_gen_readme_tables", GEN_SCRIPT,
35
+ )
36
+ assert spec and spec.loader, f"Impossible de charger {GEN_SCRIPT}"
37
+ mod = importlib.util.module_from_spec(spec)
38
+ spec.loader.exec_module(mod)
39
+ return mod
40
+
41
+
42
  def _read_readme() -> str:
43
  return README.read_text(encoding="utf-8")
44
 
 
141
  )
142
 
143
 
 
 
 
 
 
 
 
 
 
144
  def test_readme_tables_consistent_with_code() -> None:
145
+ """Le README doit être synchronisé avec le contenu généré par
146
+ ``scripts/gen_readme_tables.py``.
147
+
148
+ Appel programmatique direct (pas de ``subprocess.run``) : le script
149
+ n'invoque plus ``pytest --collect-only`` depuis le retrait du
150
+ compteur de tests en prose, l'appel direct est donc sûr et n'a
151
+ plus aucun risque de récursion pytest-dans-pytest."""
152
+ mod = _import_gen_script()
153
+ rc = mod.render_readme(check_only=True)
154
+ assert rc == 0, (
155
+ "Le README diverge du contenu généré par "
156
+ "scripts/gen_readme_tables.py. Lancer le script sans "
157
+ "``--check`` puis committer."
 
158
  )
159
 
160