"""Exécution réelle des suites + parseur PUR de la sortie unittest. C'est le cœur de la régression EXÉCUTÉE (mode `run`) : on lance chaque suite dans un sous-processus isolé (`python -m unittest discover -s tests`, exactement comme le CI Gitea), on parse sa sortie réelle, et on agrège les compteurs. Aucun chiffre n'est fabriqué — tout provient de la sortie de unittest (#6 anti-invention). Isolation par subprocess (et non import in-process) : les modules du mandat ont des fichiers `tests/test_*.py` aux noms qui se recouvrent → un discover global in-process provoquerait des collisions de modules. Le subprocess reproduit fidèlement l'environnement du CI. Déterminisme cross-environnement (`-S`) : chaque suite est lancée avec l'option `-S` (interpréteur SANS `site` → aucun `site-packages`/`dist-packages` sur `sys.path`). C'est le contrat exact du runner Gitea (`.gitea/workflows/ci.yml` : « stdlib pur, zéro pip » — « L'oracle jsonschema est utilisé s'il est présent, sinon ignoré »). Sans `-S`, le seul paquet tiers du corpus — l'oracle OPTIONNEL `jsonschema`, toujours gardé par `skipUnless`/`try…except ImportError` — ferait diverger la matrice : présent sur un poste dev (`skipped=0`), absent sur le runner pip-less (`skipped=N`). L'artefact `regression_run.json` commité — que `ci/check_regression.sh` exige byte-identique au run frais — devenait alors un FAUX-VERT : vert en local, ROUGE sur le runner. `-S` neutralise l'oracle partout → la matrice reflète la baseline garantie (stdlib + validateurs maison) et redevient byte-identique quel que soit ce qui est `pip install`é localement. `parse_unittest_output` est PUR (str → dict) donc testable sans exécuter quoi que ce soit — on lui injecte des sorties unittest synthétiques (OK / FAILED / skipped). """ from __future__ import annotations import os import subprocess import sys _RAN_RE = None # compilé paresseusement pour rester importable sans `re` en tête def _res(): import re return ( re.compile(r"Ran (\d+) tests? in"), re.compile(r"failures=(\d+)"), re.compile(r"errors=(\d+)"), re.compile(r"skipped=(\d+)"), ) def parse_unittest_output(text: str, returncode: int) -> dict: """Extrait les compteurs de la sortie texte de unittest. unittest écrit son résumé sur stderr : « Ran 12 tests in 0.03s » puis « OK » | « OK (skipped=2) » | « FAILED (failures=1, errors=2, skipped=3) ». `ok` reflète le code de retour du processus (source de vérité), pas un parse fragile du mot « OK ».""" ran_re, fail_re, err_re, skip_re = _res() ran_m = ran_re.search(text) fail_m = fail_re.search(text) err_m = err_re.search(text) skip_m = skip_re.search(text) ran = int(ran_m.group(1)) if ran_m else 0 failures = int(fail_m.group(1)) if fail_m else 0 errors = int(err_m.group(1)) if err_m else 0 skipped = int(skip_m.group(1)) if skip_m else 0 passed = max(ran - failures - errors - skipped, 0) return { "ran": ran, "passed": passed, "failures": failures, "errors": errors, "skipped": skipped, "ok": returncode == 0 and ran > 0, "returncode": returncode, } def run_suite(abs_path: str, *, python: str | None = None, discover_cmd: list[str] | None = None, timeout: int = 600) -> dict: """Exécute une suite dans son propre répertoire et renvoie ses compteurs. `abs_path` = répertoire du module (contenant `tests/`). Aucune sortie n'est imprimée ici — l'agrégateur décide de l'affichage.""" py = python or sys.executable # `-S` : interpréteur sans `site` → pas de `site-packages` → l'oracle tiers # optionnel `jsonschema` est absent, exactement comme sur le runner Gitea # pip-less. Garantit une matrice byte-déterministe indépendante de ce qui est # `pip install`é en local (cf. docstring du module). cmd = ["-S", *list(discover_cmd or ["-m", "unittest", "discover", "-s", "tests"])] tests_dir = os.path.join(abs_path, "tests") if not os.path.isdir(tests_dir): return { "ran": 0, "passed": 0, "failures": 0, "errors": 0, "skipped": 0, "ok": False, "returncode": -1, "note": "tests/ absent", } try: proc = subprocess.run( [py, *cmd], cwd=abs_path, capture_output=True, text=True, timeout=timeout, ) except subprocess.TimeoutExpired: return { "ran": 0, "passed": 0, "failures": 0, "errors": 0, "skipped": 0, "ok": False, "returncode": -2, "note": f"timeout {timeout}s", } # unittest écrit son résumé sur stderr ; on garde stdout en secours. return parse_unittest_output((proc.stderr or "") + (proc.stdout or ""), proc.returncode)