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:
252
scripts/anonymize_cli.py
Normal file
252
scripts/anonymize_cli.py
Normal 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())
|
||||
Reference in New Issue
Block a user