Demande utilisateur : voir défiler les étapes (chargement des dictionnaires,
des modèles...) dans le splash au démarrage — effet pro apprécié des clients.
Implémentation :
- Nouveau handler logging.Handler installé sur le root logger avant l'import
du core. Intercepte chaque log.info() et :
* Traduit le message technique en libellé "prod" lisible (table de
correspondance _LOG_TRANSLATIONS : "Gazetteers INSEE prénoms" →
"Chargement des prénoms français (INSEE)…", etc.)
* Pousse le libellé dans le splash tkinter (detail_var, label secondaire)
* Pousse aussi dans le splash natif PyInstaller via pyi_splash.update_text()
- Splash tkinter agrandi 440×200 → 480×240 pour la nouvelle ligne détail
- Couleur primaire magenta (#E91E63) pour cohérence avec la GUI principale
- Handler retiré quand le splash se ferme (évite impact sur la GUI)
L'utilisateur voit maintenant défiler :
Chargement des prénoms français (INSEE)…
Chargement des noms de famille (INSEE)…
Chargement des communes françaises (INSEE)…
Chargement des numéros FINESS…
Indexation des établissements de santé…
Chargement du lexique médical…
Chargement de la base médicamenteuse (BDPM)…
Chargement des stop-words…
Chargement du vocabulaire clinique…
Chargement des phrases protégées…
Moteur d'anonymisation prêt…
Interface prête — finalisation…
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
476 lines
18 KiB
Python
476 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""Launcher Windows — single-instance + download models on first run + launch GUI."""
|
|
import os
|
|
import sys
|
|
import traceback
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox
|
|
from pathlib import Path
|
|
import threading
|
|
import logging
|
|
|
|
# pyi_splash : module injecté par PyInstaller quand --splash est utilisé.
|
|
# Permet d'actualiser / fermer le splash natif affiché au démarrage de l'exe
|
|
# pendant la décompression --onefile (~15-30 s sur Windows). En mode dev
|
|
# (pas frozen), le module n'existe pas → fallback silencieux.
|
|
try:
|
|
import pyi_splash # type: ignore
|
|
_HAS_PYI_SPLASH = True
|
|
except Exception:
|
|
pyi_splash = None
|
|
_HAS_PYI_SPLASH = False
|
|
|
|
|
|
def _splash_update(text: str) -> None:
|
|
"""Met à jour le texte affiché sous le splash natif PyInstaller (si actif)."""
|
|
if _HAS_PYI_SPLASH:
|
|
try:
|
|
pyi_splash.update_text(text)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _splash_close() -> None:
|
|
"""Ferme le splash natif PyInstaller (si actif)."""
|
|
if _HAS_PYI_SPLASH:
|
|
try:
|
|
pyi_splash.close()
|
|
except Exception:
|
|
pass
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Single-instance guard (lock file in user's temp directory)
|
|
# ---------------------------------------------------------------------------
|
|
_lock_file = None
|
|
_lock_fd = None
|
|
|
|
def _ensure_single_instance():
|
|
"""Prevent multiple instances using a lock file.
|
|
Works reliably on Windows and Linux, including PyInstaller --onefile."""
|
|
global _lock_file, _lock_fd
|
|
import tempfile
|
|
_lock_file = Path(tempfile.gettempdir()) / "anonymisation_chcb.lock"
|
|
try:
|
|
if sys.platform == "win32":
|
|
import msvcrt
|
|
_lock_fd = open(_lock_file, "w")
|
|
msvcrt.locking(_lock_fd.fileno(), msvcrt.LK_NBLCK, 1)
|
|
else:
|
|
import fcntl
|
|
_lock_fd = open(_lock_file, "w")
|
|
fcntl.flock(_lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
return True
|
|
except (OSError, IOError):
|
|
return False
|
|
except Exception:
|
|
return True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Path resolution for PyInstaller frozen exe
|
|
# ---------------------------------------------------------------------------
|
|
if getattr(sys, 'frozen', False):
|
|
APP_DIR = Path(sys._MEIPASS)
|
|
EXE_DIR = Path(sys.executable).parent
|
|
else:
|
|
APP_DIR = Path(__file__).resolve().parent
|
|
EXE_DIR = APP_DIR
|
|
|
|
# Log file next to the exe
|
|
LOG_FILE = EXE_DIR / "anonymisation.log"
|
|
logging.basicConfig(
|
|
filename=str(LOG_FILE),
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(message)s",
|
|
)
|
|
log = logging.getLogger("launcher")
|
|
|
|
# Make embedded modules importable
|
|
sys.path.insert(0, str(APP_DIR))
|
|
os.chdir(str(APP_DIR))
|
|
|
|
log.info(f"APP_DIR={APP_DIR}")
|
|
log.info(f"EXE_DIR={EXE_DIR}")
|
|
log.info(f"frozen={getattr(sys, 'frozen', False)}")
|
|
|
|
MODELS_DIR = APP_DIR / "models"
|
|
|
|
|
|
def check_models_ready():
|
|
"""Check that the CamemBERT-bio ONNX model is present."""
|
|
onnx_path = MODELS_DIR / "camembert-bio-deid" / "onnx" / "model.onnx"
|
|
ok = onnx_path.exists()
|
|
log.info(f"CamemBERT ONNX present: {ok} ({onnx_path})")
|
|
return ok
|
|
|
|
|
|
def launch_gui():
|
|
"""Launch the main GUI with a splash screen + progression des chargements.
|
|
|
|
Pendant les 15-30 s d'import du core (gazetteers INSEE/FINESS/BDPM,
|
|
modèles ONNX), on intercepte les log.info() via un handler dédié pour
|
|
les afficher DANS LE SPLASH au fur et à mesure. L'utilisateur voit
|
|
défiler les étapes de chargement — effet pro.
|
|
|
|
Deux splashes collaborent :
|
|
- Splash natif PyInstaller (image visible AVANT le démarrage Python) :
|
|
mis à jour via pyi_splash.update_text().
|
|
- Splash tkinter ci-dessous : zone "Étapes" qui affiche la ligne courante
|
|
et la dernière étape terminée.
|
|
"""
|
|
log.info("Launching GUI...")
|
|
|
|
splash = tk.Tk()
|
|
splash.title("Anonymisation")
|
|
splash.geometry("480x240")
|
|
splash.resizable(False, False)
|
|
|
|
frame = ttk.Frame(splash, padding=20)
|
|
frame.pack(fill="both", expand=True)
|
|
ttk.Label(frame, text="Anonymisation", font=("", 14, "bold")).pack(pady=(0, 4))
|
|
ttk.Label(frame, text="Pseudonymisation de documents médicaux",
|
|
foreground="#666666").pack(pady=(0, 8))
|
|
|
|
status_var = tk.StringVar(value="Démarrage…")
|
|
ttk.Label(frame, textvariable=status_var, foreground="#E91E63",
|
|
font=("", 10, "bold")).pack(pady=(4, 4))
|
|
|
|
# Zone où défile le détail des étapes (dernier log.info interceptée)
|
|
detail_var = tk.StringVar(value="")
|
|
ttk.Label(frame, textvariable=detail_var, foreground="#666666",
|
|
font=("", 9), wraplength=440).pack(pady=(0, 8))
|
|
|
|
pb = ttk.Progressbar(frame, mode="indeterminate", length=420)
|
|
pb.pack(pady=4)
|
|
pb.start(10)
|
|
|
|
ttk.Label(frame, text="Merci de patienter — 15 à 30 secondes",
|
|
foreground="#999999", font=("", 8)).pack(pady=(6, 0))
|
|
|
|
result = {"done": False, "error": None}
|
|
|
|
# --- Handler de logs qui alimente le splash ---
|
|
# Traduit les messages techniques vers des libellés "prod" plus lisibles.
|
|
_LOG_TRANSLATIONS = [
|
|
("Gazetteers INSEE prénoms", "Chargement des prénoms français (INSEE)…"),
|
|
("Gazetteers INSEE communes", "Chargement des communes françaises (INSEE)…"),
|
|
("Gazetteers INSEE noms de famille", "Chargement des noms de famille (INSEE)…"),
|
|
("Villes blacklist", "Chargement de la blacklist des villes…"),
|
|
("Gazetteer FINESS numéros", "Chargement des numéros FINESS…"),
|
|
("Gazetteer FINESS villes", "Chargement des villes FINESS…"),
|
|
("Gazetteer FINESS téléphones", "Chargement des téléphones FINESS…"),
|
|
("Gazetteer FINESS Aho-Corasick", "Indexation des établissements de santé…"),
|
|
("Gazetteer FINESS adresses", "Chargement des adresses FINESS…"),
|
|
("Gazetteer VILLE Aho-Corasick", "Indexation des villes…"),
|
|
("Whitelist termes médicaux", "Chargement du lexique médical…"),
|
|
("Whitelist médicaments", "Chargement de la base médicamenteuse (BDPM)…"),
|
|
("Stop-words manuels", "Chargement des stop-words…"),
|
|
("BDPM stop-words", "Chargement des médicaments BDPM…"),
|
|
("DPI labels blacklist", "Chargement des libellés DPI…"),
|
|
("Companion blacklist", "Chargement du vocabulaire clinique…"),
|
|
("Whitelist phrases", "Chargement des phrases protégées…"),
|
|
("FINESS mono-mots", "Chargement des sigles d'établissement…"),
|
|
("Core imported OK", "Moteur d'anonymisation prêt…"),
|
|
("GUI module imported OK", "Interface prête — finalisation…"),
|
|
]
|
|
|
|
def _translate(msg: str) -> str:
|
|
for key, human in _LOG_TRANSLATIONS:
|
|
if key in msg:
|
|
return human
|
|
return msg
|
|
|
|
class _SplashHandler(logging.Handler):
|
|
def emit(self, record):
|
|
try:
|
|
msg = record.getMessage()
|
|
human = _translate(msg)
|
|
# Update splash tkinter (thread-safe via after)
|
|
splash.after(0, lambda m=human: detail_var.set(m[:120]))
|
|
# Update splash natif PyInstaller (si encore actif)
|
|
_splash_update(human)
|
|
except Exception:
|
|
pass
|
|
|
|
_splash_handler = _SplashHandler()
|
|
_splash_handler.setLevel(logging.INFO)
|
|
logging.getLogger().addHandler(_splash_handler)
|
|
|
|
def _do_import():
|
|
try:
|
|
status_var.set("Chargement des dictionnaires médicaux…")
|
|
import anonymizer_core_refactored_onnx # noqa
|
|
log.info("Core imported OK")
|
|
result["step"] = "gui"
|
|
status_var.set("Chargement de l'interface…")
|
|
import Pseudonymisation_Gui_V5 # noqa
|
|
log.info("GUI module imported OK")
|
|
except Exception as e:
|
|
result["error"] = f"{e}\n{traceback.format_exc()}"
|
|
log.error(f"Import error: {result['error']}")
|
|
finally:
|
|
result["done"] = True
|
|
|
|
threading.Thread(target=_do_import, daemon=True).start()
|
|
|
|
# Fermer le splash natif PyInstaller dès que le splash tkinter est à l'écran
|
|
# (sinon les deux se superposent pendant tout l'import).
|
|
splash.update() # force le rendu initial
|
|
_splash_close()
|
|
|
|
def _poll():
|
|
if result["done"]:
|
|
pb.stop()
|
|
# Retirer le handler — la GUI principale utilise ses propres logs
|
|
try:
|
|
logging.getLogger().removeHandler(_splash_handler)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
splash.destroy()
|
|
except Exception:
|
|
pass
|
|
if result["error"]:
|
|
try:
|
|
messagebox.showerror(
|
|
"Erreur",
|
|
f"Erreur au lancement :\n\n{result['error'].splitlines()[0]}\n\n"
|
|
f"Voir {LOG_FILE} pour les détails.",
|
|
)
|
|
except Exception:
|
|
pass
|
|
return
|
|
# Lancement de la GUI principale
|
|
try:
|
|
import Pseudonymisation_Gui_V5
|
|
log.info("Starting mainloop…")
|
|
root = tk.Tk()
|
|
Pseudonymisation_Gui_V5.App(root)
|
|
root.mainloop()
|
|
except Exception as e:
|
|
log.error(f"GUI error: {e}\n{traceback.format_exc()}")
|
|
try:
|
|
messagebox.showerror(
|
|
"Erreur",
|
|
f"Erreur de l'interface :\n\n{e}\n\nVoir {LOG_FILE}",
|
|
)
|
|
except Exception:
|
|
pass
|
|
else:
|
|
splash.after(200, _poll)
|
|
|
|
splash.after(200, _poll)
|
|
splash.mainloop()
|
|
|
|
|
|
class SetupWindow:
|
|
"""Setup window for first launch — auto-démarre le téléchargement des modèles.
|
|
|
|
Affiche un suivi détaillé par modèle (EDS-Pseudo, GLiNER, CamemBERT-bio) avec
|
|
indicateurs visuels (⏳ en cours, ✓ succès, ✗ échec). Permet de relancer en
|
|
cas d'erreur. Lancement auto de la GUI une fois tous les modèles prêts.
|
|
"""
|
|
|
|
# Liste ordonnée des étapes de chargement. Chaque entrée :
|
|
# (clé interne, libellé, taille approx, fonction de chargement)
|
|
STEPS = [
|
|
("eds_pseudo", "EDS-Pseudo (CamemBERT clinique)", "~450 Mo"),
|
|
("gliner", "GLiNER (détection PII zero-shot)", "~300 Mo"),
|
|
("camembert_onnx", "CamemBERT-bio ONNX (embarqué)", "local"),
|
|
]
|
|
|
|
def __init__(self):
|
|
self.root = tk.Tk()
|
|
self.root.title("Anonymisation — Configuration initiale")
|
|
self.root.geometry("620x450")
|
|
self.root.resizable(False, False)
|
|
|
|
frame = ttk.Frame(self.root, padding=20)
|
|
frame.pack(fill="both", expand=True)
|
|
|
|
ttk.Label(frame, text="Préparation des modèles d'intelligence artificielle",
|
|
font=("", 13, "bold")).pack(pady=(0, 4))
|
|
ttk.Label(
|
|
frame,
|
|
text=("Au premier lancement, les modèles de détection doivent être téléchargés\n"
|
|
"depuis HuggingFace. Cette opération est unique — durée 3 à 10 minutes\n"
|
|
"selon votre connexion internet. Merci de patienter."),
|
|
justify="center", foreground="#555555",
|
|
).pack(pady=(0, 12))
|
|
|
|
# Barre de progression globale
|
|
self.progress = ttk.Progressbar(frame, mode="determinate",
|
|
length=560, maximum=len(self.STEPS))
|
|
self.progress.pack(pady=(0, 4))
|
|
|
|
self.status_var = tk.StringVar(value="Démarrage…")
|
|
ttk.Label(frame, textvariable=self.status_var, foreground="#1a568e").pack(pady=(0, 12))
|
|
|
|
# Zone détail par modèle
|
|
detail_frame = ttk.LabelFrame(frame, text=" Modèles ", padding=10)
|
|
detail_frame.pack(fill="x", pady=(0, 12))
|
|
|
|
self.step_labels = {}
|
|
for key, title, size in self.STEPS:
|
|
row = ttk.Frame(detail_frame)
|
|
row.pack(fill="x", pady=3)
|
|
icon = ttk.Label(row, text="○", width=3, font=("", 12))
|
|
icon.pack(side="left")
|
|
ttk.Label(row, text=title).pack(side="left")
|
|
ttk.Label(row, text=f" ({size})", foreground="#999999",
|
|
font=("", 8)).pack(side="left")
|
|
self.step_labels[key] = icon
|
|
|
|
# Bouton relance (caché au début)
|
|
self.btn = ttk.Button(frame, text="Relancer", command=self.start_download)
|
|
self.btn.pack(pady=6)
|
|
self.btn.pack_forget()
|
|
|
|
# Bouton ignorer/continuer (affiché si échec partiel)
|
|
self.btn_skip = ttk.Button(
|
|
frame, text="Continuer malgré tout",
|
|
command=self._finish,
|
|
)
|
|
self.btn_skip.pack(pady=(0, 4))
|
|
self.btn_skip.pack_forget()
|
|
|
|
# Auto-démarrage du téléchargement (pas besoin de cliquer)
|
|
self.root.after(500, self.start_download)
|
|
|
|
def start_download(self):
|
|
self.btn.pack_forget()
|
|
self.btn_skip.pack_forget()
|
|
self.progress["value"] = 0
|
|
self.status_var.set("Démarrage du téléchargement…")
|
|
for icon in self.step_labels.values():
|
|
icon.configure(text="○", foreground="#999999")
|
|
threading.Thread(target=self._download_thread, daemon=True).start()
|
|
|
|
def _set_step(self, key, state):
|
|
"""state : 'pending' | 'running' | 'ok' | 'fail'"""
|
|
mapping = {
|
|
"pending": ("○", "#999999"),
|
|
"running": ("⏳", "#f57c00"),
|
|
"ok": ("✓", "#2e7d32"),
|
|
"fail": ("✗", "#c62828"),
|
|
}
|
|
char, color = mapping.get(state, ("○", "#999999"))
|
|
icon = self.step_labels.get(key)
|
|
if icon is not None:
|
|
self.root.after(0, lambda: icon.configure(text=char, foreground=color))
|
|
|
|
def _download_thread(self):
|
|
failures = []
|
|
try:
|
|
# 1. EDS-Pseudo
|
|
self._update("Téléchargement d'EDS-Pseudo… (modèle CamemBERT clinique)")
|
|
self._set_step("eds_pseudo", "running")
|
|
log.info("Downloading EDS-Pseudo...")
|
|
try:
|
|
from eds_pseudo_manager import EdsPseudoManager
|
|
mgr = EdsPseudoManager()
|
|
mgr.load()
|
|
self._set_step("eds_pseudo", "ok")
|
|
log.info("EDS-Pseudo OK")
|
|
except Exception as e:
|
|
self._set_step("eds_pseudo", "fail")
|
|
failures.append(("EDS-Pseudo", str(e)))
|
|
log.warning(f"EDS-Pseudo failed: {e}")
|
|
self._advance()
|
|
|
|
# 2. GLiNER
|
|
self._update("Téléchargement de GLiNER… (détection zero-shot)")
|
|
self._set_step("gliner", "running")
|
|
log.info("Downloading GLiNER...")
|
|
try:
|
|
from gliner_manager import GlinerManager
|
|
mgr = GlinerManager()
|
|
mgr.load()
|
|
self._set_step("gliner", "ok")
|
|
log.info("GLiNER OK")
|
|
except Exception as e:
|
|
self._set_step("gliner", "fail")
|
|
failures.append(("GLiNER", str(e)))
|
|
log.warning(f"GLiNER failed: {e}")
|
|
self._advance()
|
|
|
|
# 3. CamemBERT-bio ONNX
|
|
self._update("Vérification CamemBERT-bio ONNX (modèle embarqué)…")
|
|
self._set_step("camembert_onnx", "running")
|
|
if check_models_ready():
|
|
self._set_step("camembert_onnx", "ok")
|
|
else:
|
|
self._set_step("camembert_onnx", "fail")
|
|
failures.append(("CamemBERT-bio ONNX", "fichier ONNX introuvable dans le bundle"))
|
|
log.error("CamemBERT-bio ONNX not found")
|
|
self._advance()
|
|
|
|
if failures:
|
|
lines = "\n".join(f" • {name} : {err[:60]}" for name, err in failures)
|
|
self._update(f"Certains modèles ont échoué ({len(failures)}/{len(self.STEPS)}).")
|
|
log.warning(f"Setup partial failure: {len(failures)} model(s) failed\n{lines}")
|
|
self.root.after(0, lambda: self.btn.pack(pady=6))
|
|
self.root.after(0, lambda: self.btn_skip.pack(pady=(0, 4)))
|
|
else:
|
|
self._update("Tous les modèles sont prêts. Lancement de l'interface…")
|
|
log.info("Setup complete, launching GUI in 1.5s")
|
|
self.root.after(1500, self._finish)
|
|
|
|
except Exception as e:
|
|
log.error(f"Setup error: {e}\n{traceback.format_exc()}")
|
|
self._update(f"Erreur inattendue : {e}")
|
|
self.root.after(0, lambda: self.btn.pack(pady=6))
|
|
|
|
def _advance(self):
|
|
self.root.after(0, lambda: self.progress.step(1))
|
|
|
|
def _update(self, msg):
|
|
self.root.after(0, lambda: self.status_var.set(msg))
|
|
|
|
def _finish(self):
|
|
try:
|
|
self.root.destroy()
|
|
except Exception:
|
|
pass
|
|
launch_gui()
|
|
|
|
def run(self):
|
|
self.root.mainloop()
|
|
|
|
|
|
def main():
|
|
log.info("=== Demarrage Anonymisation ===")
|
|
|
|
# Single-instance check
|
|
if not _ensure_single_instance():
|
|
log.warning("Another instance is already running. Exiting.")
|
|
try:
|
|
messagebox.showwarning(
|
|
"Anonymisation",
|
|
"L'application est deja en cours d'execution.\n\n"
|
|
"Regardez dans la barre des taches.",
|
|
)
|
|
except:
|
|
pass
|
|
sys.exit(0)
|
|
|
|
try:
|
|
if check_models_ready():
|
|
_splash_update("Modèles déjà installés — chargement…")
|
|
launch_gui()
|
|
else:
|
|
_splash_update("Premier lancement — configuration initiale")
|
|
_splash_close() # laisse place à la SetupWindow qui a sa propre UI
|
|
setup = SetupWindow()
|
|
setup.run()
|
|
except Exception as e:
|
|
log.error(f"Fatal error: {e}\n{traceback.format_exc()}")
|
|
try:
|
|
messagebox.showerror("Erreur fatale", f"{e}\n\nVoir {LOG_FILE}")
|
|
except:
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|