From 263126dafa49f3765614e2da2c23d48abe06c28e Mon Sep 17 00:00:00 2001 From: Domi31tls Date: Wed, 10 Jun 2026 14:26:11 +0200 Subject: [PATCH] feat(cli): add Windows single-file anonymization entrypoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` (+ --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) --- anonymisation_cli_onefile.spec | 120 ++++++++++++++++ docs/build-windows-oneclick.md | 75 ++++++++++ scripts/anonymize_cli.py | 252 +++++++++++++++++++++++++++++++++ 3 files changed, 447 insertions(+) create mode 100644 anonymisation_cli_onefile.spec create mode 100644 scripts/anonymize_cli.py diff --git a/anonymisation_cli_onefile.spec b/anonymisation_cli_onefile.spec new file mode 100644 index 0000000..50af45a --- /dev/null +++ b/anonymisation_cli_onefile.spec @@ -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 +# 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, +) diff --git a/docs/build-windows-oneclick.md b/docs/build-windows-oneclick.md index c6103f6..a1fae02 100644 --- a/docs/build-windows-oneclick.md +++ b/docs/build-windows-oneclick.md @@ -71,6 +71,81 @@ powershell -ExecutionPolicy Bypass -File .\scripts\build_windows_oneclick.ps1 -S - le build doit être lancé depuis Windows - le modèle ONNX embarqué requis doit exister localement dans : `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 `. + +Sorties produites dans le dossier demandé (identiques à la GUI v5, burn raster) : +`.redacted_raster.pdf`, `.pseudonymise.txt`, `.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 diff --git a/scripts/anonymize_cli.py b/scripts/anonymize_cli.py new file mode 100644 index 0000000..a0c4c3c --- /dev/null +++ b/scripts/anonymize_cli.py @@ -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 + +- 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())