#!/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 during the slow core import. Le chargement des gazetteers (INSEE 200k+ noms, FINESS 100k+ établissements, BDPM 7k+ médicaments) prend 10–30 s. Sans feedback visuel, l'utilisateur croit que l'application est plantée. Deux splashes collaborent : - Splash natif PyInstaller (image) : visible AVANT le démarrage Python, pendant la décompression --onefile dans %TEMP%. - Splash tkinter ci-dessous : progressbar + messages pendant l'import des modèles et la construction de la GUI. """ log.info("Launching GUI...") _splash_update("Chargement de l'interface…") splash = tk.Tk() splash.title("Anonymisation") splash.geometry("440x200") 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, 12)) status_var = tk.StringVar(value="Chargement des dictionnaires médicaux…") ttk.Label(frame, textvariable=status_var, foreground="#1a568e").pack(pady=(0, 8)) pb = ttk.Progressbar(frame, mode="indeterminate", length=380) pb.pack(pady=4) pb.start(10) ttk.Label(frame, text="Première phase : ~15–30 s", foreground="#999999", font=("", 8)).pack(pady=(6, 0)) result = {"done": False, "error": None} def _do_import(): try: import anonymizer_core_refactored_onnx # noqa log.info("Core imported OK") result["step"] = "gui" 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(): # Met à jour le message selon l'étape atteinte par le thread d'import if result.get("step") == "gui" and not result["done"]: status_var.set("Chargement de l'interface…") if result["done"]: pb.stop() 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()