feat(cli): add Windows single-file anonymization entrypoint

CLI de production sans GUI pour anonymiser un fichier unique, validé GO par
Qwen (revue indépendante contrat/packaging/modèles) sur de vrais PDF.

- scripts/anonymize_cli.py (NOUVEAU) : contrat positionnel
  `Anonymisation-CLI.exe <fichier> <dossier_sortie>` (+ --out compat),
  chemins espaces/accents, codes retour 0/1/2/3/4.
  Chargement modèles fail-closed : CamemBERT-bio ONNX OBLIGATOIRE (code 3 si
  absent, aucun mode dégradé silencieux) ; EDS-Pseudo + GLiNER optionnels,
  tracés au log ; --no-ner = regex seul assumé. Résolution _MEIPASS frozen
  alignée sur launcher.py. Sortie burn raster identique GUI v5.
- anonymisation_cli_onefile.spec : entrypoint basculé vers anonymize_cli.py
  (le harnais perf D-19 anonymize_batch_cli.py reste hors build).
- docs/build-windows-oneclick.md : section « CLI Windows (sans GUI) »
  (build, usage, codes retour, modèles, limitations).

Tests Linux (vrais PDF) : --help OK, fichier manquant→2, --no-ner accents→0,
NER complet→0 (CamemBERT-bio + EDS-Pseudo chargés), modèle déplacé→3.
Build/smoke Windows à suivre (séparé). Commit CLI-only strict, distinct du P0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 14:26:11 +02:00
parent 0e44cd4543
commit 263126dafa
3 changed files with 447 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
import os
from pathlib import Path
# Spec CLI frozen — EXE de PRODUCTION (anonymisation fichier unique sans GUI).
# Même moteur / mêmes datas que anonymisation_onefile.spec, mais :
# - entrypoint = scripts/anonymize_cli.py (CLI production, pas launcher.py)
# Contrat : Anonymisation-CLI.exe <fichier> <dossier_sortie>
# Modèle CamemBERT-bio ONNX OBLIGATOIRE (fail-closed, code 3 si absent).
# - console=True (CLI), pas de Splash
# - name = Anonymisation-CLI -> ne remplace pas dist/Anonymisation.exe
# (Le harnais perf D-19 reste scripts/anonymize_batch_cli.py, non buildé ici.)
block_cipher = None
project_dir = Path(globals().get("SPECPATH", os.getcwd())).resolve()
def _data_entry(relative_path: str, target_dir: str | None = None):
src = project_dir / relative_path
if not src.exists():
return None
return (str(src), target_dir or relative_path)
datas = []
for relative_path, target_dir in [
("config", "config"),
("data/bdpm", "data/bdpm"),
("data/finess", "data/finess"),
("data/insee", "data/insee"),
("models/camembert-bio-deid/onnx", "models/camembert-bio-deid/onnx"),
("detectors", "detectors"),
("scripts", "scripts"),
("assets", "assets"),
]:
entry = _data_entry(relative_path, target_dir)
if entry is not None:
datas.append(entry)
for relative_path in [
"data/stopwords_manuels.txt",
"data/villes_blacklist.txt",
"data/dpi_labels_blacklist.txt",
"data/companion_blacklist.txt",
]:
entry = _data_entry(relative_path, "data")
if entry is not None:
datas.append(entry)
hiddenimports = [
"anonymizer_core_refactored_onnx",
"admin_rules",
"config_defaults",
"profile_defaults",
"gui_batch_paths",
"manual_masking",
"pdf_mask_designer",
"format_converter",
"ner_manager_onnx",
"camembert_ner_manager",
"eds_pseudo_manager",
"gliner_manager",
"vlm_manager",
"build_info",
"doctr",
"doctr.io",
"doctr.models",
"doctr.models.detection",
"doctr.models.recognition",
"cv2",
"torchvision",
"edsnlp",
"edsnlp.pipes",
"edsnlp.pipes.ner",
"edsnlp.pipes.ner.pseudo",
"spacy",
"spacy.lang.fr",
"gliner",
"onnxruntime",
"transformers",
"tokenizers",
"torch",
"pdfplumber",
"fitz",
"PIL",
"yaml",
"loguru",
"regex",
"optimum",
"optimum.onnxruntime",
"optimum.pipelines",
"optimum.modeling_base",
"optimum.exporters.onnx",
]
a = Analysis(
[str(project_dir / "scripts" / "anonymize_cli.py")],
pathex=[str(project_dir)],
datas=datas,
hiddenimports=hiddenimports,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name="Anonymisation-CLI",
debug=False,
strip=False,
upx=False,
console=True,
)

View File

@@ -71,6 +71,81 @@ powershell -ExecutionPolicy Bypass -File .\scripts\build_windows_oneclick.ps1 -S
- le build doit être lancé depuis Windows - le build doit être lancé depuis Windows
- le modèle ONNX embarqué requis doit exister localement dans : - le modèle ONNX embarqué requis doit exister localement dans :
`models\camembert-bio-deid\onnx\model.onnx` `models\camembert-bio-deid\onnx\model.onnx`
- limitation MVP frozen : voir `docs/limitations-frozen-mvp.md` pour les moteurs
effectivement embarqués, notamment l'absence d'EDS-Pseudo dans le paquet MVP.
## CLI Windows (sans GUI) — fichier unique
En plus de la GUI, un exécutable **CLI de production** permet d'anonymiser un
fichier (ou un dossier) en ligne de commande, sans interface graphique.
- entrypoint : `scripts/anonymize_cli.py`
- spec PyInstaller : `anonymisation_cli_onefile.spec`
- exécutable produit : `dist\Anonymisation-CLI.exe` (ne remplace pas
`Anonymisation.exe`)
### Build CLI
Sur la machine Windows de build, dans le venv de build :
```powershell
pyinstaller --noconfirm --clean anonymisation_cli_onefile.spec
```
Le `.spec` embarque les mêmes ressources que la GUI : `config\`, `data\`,
`models\camembert-bio-deid\onnx\`, `detectors\`, `assets\`, plus les
hiddenimports NER / docTR / ONNX. Le modèle ONNX obligatoire
`models\camembert-bio-deid\onnx\model.onnx` doit exister localement avant le
build (sinon il ne sera pas embarqué et le CLI échouera au lancement).
### Utilisation
```powershell
Anonymisation-CLI.exe "C:\chemin\document.pdf" "C:\chemin\sortie"
Anonymisation-CLI.exe --help
```
- argument 1 : fichier unique existant (ou dossier parcouru récursivement) ;
- argument 2 : dossier de sortie (créé si absent) ; `--out` reste accepté ;
- chemins avec espaces et accents supportés ;
- options : `--no-ner` (regex seul), `--gliner` (vote croisé optionnel),
`--limit N`, `--config <dictionnaires.yml>`.
Sorties produites dans le dossier demandé (identiques à la GUI v5, burn raster) :
`<doc>.redacted_raster.pdf`, `<doc>.pseudonymise.txt`, `<doc>.audit.jsonl`.
Un log lisible est écrit à côté de l'EXE : `anonymisation_cli.log`.
### Codes retour
| Code | Signification |
|------|---------------|
| `0` | anonymisation terminée, sortie produite |
| `1` | erreur de traitement (exception) |
| `2` | entrée manquante (fichier/dossier introuvable, aucun document) |
| `3` | modèle OBLIGATOIRE absent / illisible (fail-closed, pas de mode dégradé) |
| `4` | sortie non produite (quarantaine résiduelle ou PDF absent) |
### Modèles (dernière version du moteur)
- **OBLIGATOIRE** : CamemBERT-bio ONNX (`models\camembert-bio-deid\onnx\model.onnx`).
Embarqué dans le build. S'il est absent/illisible et que le NER est demandé,
le CLI **échoue clairement (code 3)** — il n'affiche jamais « OK » en mode
dégradé silencieux.
- **OPTIONNELS** : EDS-Pseudo, GLiNER. Chargés best effort et **tracés dans le
log** ; leur absence est signalée explicitement, jamais masquée. ⚠️ EDS-Pseudo
peut ne pas être embarqué dans le paquet MVP frozen — voir
`docs/limitations-frozen-mvp.md`. Dans ce cas le log indique
« EDS-Pseudo (optionnel) INDISPONIBLE » et le traitement se poursuit avec
CamemBERT-bio ONNX (impact qualité faible, validé en bêta interne).
- `--no-ner` : mode regex seul **assumé** par l'opérateur (aucun modèle
obligatoire), à n'utiliser qu'en connaissance de cause.
### Limitations CLI frozen
- pas d'EDS-Pseudo garanti dans le MVP frozen (cf. ci-dessus) ;
- pas de dépendance internet : tous les modèles déclarés obligatoires sont
locaux/embarqués ;
- rastérisation séquentielle en mode frozen (cf. limitations GUI).
## Blocage Windows / SmartScreen ## Blocage Windows / SmartScreen

252
scripts/anonymize_cli.py Normal file
View File

@@ -0,0 +1,252 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""CLI de production — anonymise UN fichier (ou un dossier) sans GUI.
Contrat principal (demande Dom 2026-06-10) :
Anonymisation-CLI.exe <fichier> <dossier_sortie>
- argument 1 : fichier unique existant (ou dossier parcouru récursivement) ;
- argument 2 : dossier de sortie (créé si absent) ; `--out` reste accepté en
compatibilité ;
- chemins avec espaces et accents supportés ;
- pas de GUI ;
- log lisible à côté de l'EXE (frozen) ou dans le cwd (venv).
Codes retour :
- 0 : anonymisation terminée, sortie produite ;
- 2 : entrée manquante (fichier/dossier introuvable ou aucun document) ;
- 3 : modèle OBLIGATOIRE indisponible (échec fail-closed, pas de mode dégradé
silencieux) ;
- 4 : sortie non produite (mise en quarantaine résiduelle ou fichier absent) ;
- 1 : erreur de traitement (exception).
Modèles (alignés sur la dernière version du moteur, comme la GUI v5) :
- OBLIGATOIRE : CamemBERT-bio ONNX (`models/camembert-bio-deid/onnx/model.onnx`).
Si absent/illisible et NER demandé -> le CLI ÉCHOUE (code 3), il n'affiche
jamais « OK » en mode dégradé.
- OPTIONNELS : EDS-Pseudo, GLiNER. Chargés best effort, tracés dans le log ;
leur absence est explicitement signalée, jamais masquée.
- `--no-ner` : mode regex seul ASSUMÉ par l'opérateur -> aucun modèle obligatoire.
Compatible frozen (PyInstaller) ET venv : en frozen, se positionne dans
`sys._MEIPASS` (comme launcher.py) pour que le core retrouve config/ data/
models/ ; écrit le log à côté de l'EXE ; résout les chemins entrée/sortie
contre le cwd de lancement.
Sortie de production (identique à la GUI v5) : burn raster
(`make_vector_redaction=False`, `also_make_raster_burn=True`), plus le texte
pseudonymisé (`.pseudonymise.txt`) et l'audit (`.audit.jsonl`).
"""
from __future__ import annotations
import argparse
import logging
import os
import sys
import time
from pathlib import Path
# Console Windows (cp1252) : forcer UTF-8 pour éviter UnicodeEncodeError sur les
# accents (le FileHandler est déjà en utf-8).
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
except Exception:
pass
# cwd de lancement, capturé AVANT tout chdir (sert à résoudre entrée/sortie).
_LAUNCH_CWD = Path.cwd()
# Résolution des chemins en frozen, alignée sur launcher.py : les datas
# (config/, data/, models/) sont extraites dans _MEIPASS ; on s'y positionne
# pour que le core les trouve. Le log va à côté de l'EXE.
if getattr(sys, "frozen", False):
_APP_DIR = Path(sys._MEIPASS) # type: ignore[attr-defined]
_EXE_DIR = Path(sys.executable).parent
sys.path.insert(0, str(_APP_DIR))
os.chdir(str(_APP_DIR))
_LOG_PATH = _EXE_DIR / "anonymisation_cli.log"
else:
_APP_DIR = Path(__file__).resolve().parent.parent
if str(_APP_DIR) not in sys.path:
sys.path.insert(0, str(_APP_DIR))
_LOG_PATH = Path("anonymisation_cli.log")
# Extensions acceptées (le core convertit les formats non-PDF en amont via
# format_converter ; ici on collecte large et on laisse le moteur trancher).
_SUPPORTED_EXT = {
".pdf", ".docx", ".odt", ".rtf", ".txt", ".html", ".htm",
".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp",
}
def _resolve(p: str) -> Path:
"""Résout un chemin relatif contre le cwd de lancement (pas _MEIPASS)."""
path = Path(p)
return path if path.is_absolute() else (_LAUNCH_CWD / path)
def _mandatory_model_path() -> Path:
"""Chemin attendu du modèle ONNX obligatoire (relatif à l'app/_MEIPASS)."""
return _APP_DIR / "models" / "camembert-bio-deid" / "onnx" / "model.onnx"
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(
"output", nargs="?", default=None,
help="Dossier de sortie (forme positionnelle). Créé si absent.",
)
ap.add_argument("--out", default=None, help="Dossier de sortie (compatibilité). Équivaut à l'argument positionnel.")
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("--config", default=None, help="Chemin config dictionnaires.yml (défaut: runtime)")
args = ap.parse_args(argv)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(str(_LOG_PATH), encoding="utf-8"),
],
)
log = logging.getLogger("anon_cli")
log.info("CLI: démarrage frozen=%s python=%s log=%s",
bool(getattr(sys, "frozen", False)), sys.version.split()[0], _LOG_PATH)
# Dossier de sortie : positionnel prioritaire, puis --out, sinon défaut explicite.
out_arg = args.output or args.out or "anonymise_out"
out_root = _resolve(out_arg)
# --- Entrée : fichier unique (contrat principal) ou dossier ---
inp = _resolve(args.input)
if not inp.exists():
log.error("CLI: entrée introuvable: %s", inp)
return 2
if inp.is_file():
docs = [inp]
else:
docs = sorted(p for p in inp.rglob("*") if p.is_file() and p.suffix.lower() in _SUPPORTED_EXT)
if args.limit > 0:
docs = docs[: args.limit]
if not docs:
log.error("CLI: aucun document supporté trouvé sous %s", inp)
return 2
import anonymizer_core_refactored_onnx as core
# H1 : aligne les threads torch (idempotent).
if hasattr(core, "_configure_torch_threads"):
core._configure_torch_threads()
# --- Modèles ---
# OBLIGATOIRE (sauf --no-ner) : CamemBERT-bio ONNX. Fail-closed.
eds_mgr = camembert_mgr = gliner_mgr = None
if not args.no_ner:
model_path = _mandatory_model_path()
if not model_path.exists():
log.error("CLI: MODÈLE OBLIGATOIRE absent: %s. "
"Build incomplet ou ressource manquante. Abandon (pas de mode dégradé). "
"Utilisez --no-ner pour forcer le mode regex seul.", model_path)
return 3
try:
from camembert_ner_manager import CamembertNerManager
camembert_mgr = CamembertNerManager()
camembert_mgr.load()
log.info("CLI: CamemBERT-bio ONNX chargé (obligatoire) ✓")
except Exception as e: # noqa: BLE001
log.error("CLI: échec chargement du modèle OBLIGATOIRE CamemBERT-bio ONNX: %s. "
"Abandon (pas de mode dégradé silencieux). "
"Utilisez --no-ner pour forcer le mode regex seul.", e)
return 3
# OPTIONNEL : EDS-Pseudo (best effort, tracé).
try:
from eds_pseudo_manager import EdsPseudoManager
eds_mgr = EdsPseudoManager()
eds_mgr.load()
log.info("CLI: EDS-Pseudo chargé (optionnel) ✓")
except Exception as e: # noqa: BLE001
log.warning("CLI: EDS-Pseudo (optionnel) INDISPONIBLE: %s — traitement poursuivi sans.", e)
# OPTIONNEL : GLiNER (sur demande).
if args.gliner:
try:
from gliner_manager import GlinerManager
gliner_mgr = GlinerManager()
gliner_mgr.load()
log.info("CLI: GLiNER chargé (optionnel) ✓")
except Exception as e: # noqa: BLE001
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). "
"Qualité réduite : à n'utiliser qu'en connaissance de cause.")
use_ner = bool(eds_mgr or gliner_mgr or camembert_mgr)
log.info("CLI: %d document(s), ner=%s (camembert=%s eds=%s gliner=%s) -> sortie=%s",
len(docs), use_ner, bool(camembert_mgr), bool(eds_mgr), bool(gliner_mgr), out_root)
config_path = _resolve(args.config) if args.config else None
process_fn = getattr(core, "process_document", None) or core.process_pdf
path_key = "doc_path" if process_fn.__name__ == "process_document" else "pdf_path"
out_root.mkdir(parents=True, exist_ok=True)
t0 = time.perf_counter()
ok = ko = quarantined = no_output = 0
for i, doc in enumerate(docs, 1):
td = time.perf_counter()
try:
res = process_fn(
**{path_key: doc},
out_dir=out_root,
make_vector_redaction=False,
also_make_raster_burn=True,
config_path=config_path,
use_hf=use_ner,
ner_manager=eds_mgr,
gliner_manager=gliner_mgr,
camembert_manager=camembert_mgr,
ogc_label=None,
)
dt = time.perf_counter() - td
if isinstance(res, dict) and res.get("status") == "quarantined":
quarantined += 1
log.warning("CLI: [%d/%d] %s QUARANTAINE reason=%s %.2fs — sortie NON produite.",
i, len(docs), doc.name, res.get("reason", "?"), dt)
continue
# Vérifie qu'une sortie anonymisée a bien été produite.
produced = []
if isinstance(res, dict):
produced = [v for k, v in res.items() if k.startswith("pdf") and v and Path(v).exists()]
if not produced:
no_output += 1
log.error("CLI: [%d/%d] %s AUCUNE sortie PDF produite (%.2fs).", i, len(docs), doc.name, dt)
continue
ok += 1
log.info("CLI: [%d/%d] %s OK %.2fs -> %s", i, len(docs), doc.name, dt,
", ".join(Path(p).name for p in produced))
except Exception as e: # noqa: BLE001
ko += 1
log.error("CLI: [%d/%d] %s ERREUR: %s", i, len(docs), doc.name, e)
total = time.perf_counter() - t0
log.info("CLI: DONE total=%.1fs ok=%d quarantined=%d no_output=%d ko=%d",
total, ok, quarantined, no_output, ko)
# Codes retour : priorité aux échecs durs.
if ko:
return 1
if quarantined or no_output:
return 4
return 0 if ok else 4
if __name__ == "__main__":
raise SystemExit(main())