L'exe --onefile décompresse ~720 Mo dans %TEMP% au lancement. Sur Windows, cela prend 15-30 s AVANT que Python ne démarre. Pendant ce temps : - Aucune fenêtre visible (même le splash tkinter existant n'était pas encore exécuté, car il faut d'abord l'import de Python). - L'utilisateur clique parfois plusieurs fois, croit que l'app est plantée. Solution : Splash natif PyInstaller (Splash() dans le .spec). L'image est affichée PAR LE BOOTLOADER de l'exe, AVANT même le démarrage Python. Le texte sous l'image est actualisable via pyi_splash.update_text(), puis fermé via pyi_splash.close() une fois le splash tkinter visible. Changements : - assets/splash.png (480x240) : titre + sous-titre + indication de durée - anonymisation_onefile.spec : Splash() + splash/splash.binaries dans EXE() - launcher.py : import pyi_splash (fallback silencieux en mode dev), helpers _splash_update / _splash_close, fermeture du splash natif dès que le splash tkinter est à l'écran (évite superposition). - .gitignore : exception !assets/** pour versionner l'image du splash (règle générale *.png exclut tout le reste). Effet utilisateur attendu : fenêtre visible IMMÉDIATEMENT au double-clic, avec message "Démarrage en cours — merci de patienter…". Suppression du trou noir de 15-30 s. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
418 lines
15 KiB
Python
418 lines
15 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 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()
|