feat(cli): option --engines, diagnostic honnête des moteurs du build CLI

Axe CLI (intégration dans d'autres programmes) : contrat stable, codes retour
fiables. `--engines` liste les moteurs réellement disponibles dans CET
exécutable CLI (`[OUI]/[NON] Label (requis/optionnel) — raison`) et sort 0, sans
traiter. `input` devient optionnel pour ce mode (sinon code 2).

Le fail-closed CamemBERT (code 3) et le best-effort EDS/GLiNER (jamais déclarés
actifs si le chargement échoue) restent inchangés. Ne présume pas du build GUI.

2 tests (--engines → code 0 + moteurs listés ; absence d'input → code 2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 17:38:56 +02:00
parent cb3b7675bb
commit 890edb360e
2 changed files with 68 additions and 1 deletions

View File

@@ -103,12 +103,29 @@ def _mandatory_model_path() -> Path:
return _APP_DIR / "models" / "camembert-bio-deid" / "onnx" / "model.onnx"
def _print_engines() -> int:
"""Affiche les moteurs réellement disponibles dans cet exécutable.
Diagnostic « honnête » : ne déclare jamais disponible un moteur dont les
dépendances (ou le modèle, pour CamemBERT) ne chargent pas. Sortie 0.
"""
from engine_capabilities import capabilities_map
caps = capabilities_map()
print("Moteurs d'anonymisation — disponibilité dans cet exécutable :")
for cap in caps.values():
mark = "OUI" if cap.available else "NON"
flag = "requis" if cap.required else "optionnel"
print(f" [{mark}] {cap.label} ({flag}) — {cap.reason}")
return 0
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(
prog="Anonymisation-CLI",
description="Anonymise un fichier (ou dossier) sans GUI.",
)
ap.add_argument("input", help="Fichier unique existant (ou dossier parcouru récursivement)")
ap.add_argument("input", nargs="?", default=None, help="Fichier unique existant (ou dossier parcouru récursivement)")
ap.add_argument(
"output", nargs="?", default=None,
help="Dossier de sortie (forme positionnelle). Créé si absent.",
@@ -117,9 +134,17 @@ def main(argv: list[str] | None = None) -> int:
ap.add_argument("--limit", type=int, default=0, help="Nombre max de documents (0 = tous ; utile pour un dossier)")
ap.add_argument("--no-ner", action="store_true", help="Mode regex seul : désactive EDS-Pseudo + CamemBERT (aucun modèle obligatoire)")
ap.add_argument("--gliner", action="store_true", help="Active aussi GLiNER (optionnel, vote croisé)")
ap.add_argument("--engines", action="store_true", help="Liste les moteurs réellement disponibles dans cet exécutable et quitte (diagnostic honnête, code 0)")
ap.add_argument("--config", default=None, help="Chemin config dictionnaires.yml (défaut: runtime)")
args = ap.parse_args(argv)
# --- Diagnostic moteurs : honnêteté sur ce que le build embarque réellement ---
if args.engines:
return _print_engines()
if args.input is None:
ap.error("argument 'input' requis (sauf avec --engines)")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
@@ -179,6 +204,7 @@ def main(argv: list[str] | None = None) -> int:
eds_mgr.load()
log.info("CLI: EDS-Pseudo chargé (optionnel) ✓")
except Exception as e: # noqa: BLE001
eds_mgr = None
log.warning("CLI: EDS-Pseudo (optionnel) INDISPONIBLE: %s — traitement poursuivi sans.", e)
# OPTIONNEL : GLiNER (sur demande).
@@ -189,6 +215,7 @@ def main(argv: list[str] | None = None) -> int:
gliner_mgr.load()
log.info("CLI: GLiNER chargé (optionnel) ✓")
except Exception as e: # noqa: BLE001
gliner_mgr = None
log.warning("CLI: GLiNER (optionnel) INDISPONIBLE: %s — traitement poursuivi sans.", e)
else:
log.warning("CLI: --no-ner -> MODE REGEX SEUL assumé (aucun modèle NER). "

View File

@@ -0,0 +1,40 @@
"""Le CLI expose `--engines` : diagnostic honnête des moteurs embarqués (code 0).
On charge le module CLI par chemin (il n'est pas packagé) et on vérifie que
`--engines` liste les moteurs et sort 0, sans exiger d'argument `input`.
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
import pytest
_CLI_PATH = Path(__file__).resolve().parents[2] / "scripts" / "anonymize_cli.py"
def _load_cli():
spec = importlib.util.spec_from_file_location("anonymize_cli_undertest", _CLI_PATH)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def test_engines_flag_lists_engines_and_exits_zero(capsys):
cli = _load_cli()
rc = cli.main(["--engines"])
assert rc == 0
out = capsys.readouterr().out
assert "Moteurs d'anonymisation" in out
# les 3 moteurs connus apparaissent dans le diagnostic
assert "CamemBERT-bio" in out
assert "EDS-Pseudo" in out
assert "GLiNER" in out
def test_no_input_without_engines_errors(capsys):
cli = _load_cli()
with pytest.raises(SystemExit) as exc: # argparse error => exit 2
cli.main([])
assert exc.value.code == 2