G3-A câblage moteur réel (engine_bridge.py) : EngineSettings + NerManagers à chargement paresseux (aucun manager à l'import), kwargs alignés CLI/V5 (make_vector_redaction=False, also_make_raster_burn=True, config_path, use_hf, ner/gliner/camembert_manager, ogc_label) ; make_process_fn engine injectable ; état managers not_loaded/loading/ready/unavailable, échecs optionnels tolérés. G3-B Configuration (config_state.py + tabs/tab_config.py) : ConfigState → EngineSettings, profils via profile_defaults (path injectable), options raster/NER local/profil/sortie, état managers, sections admin-only via admin_mode. G3-C Licence UI (machine_id.py + tab_about) : activation par clef (LicenseClient.activate), bouton vérifier (check), affichage statut, aucun token loggé, aucun appel réseau au démarrage (local_status seul). Intégration : tab_usage exécute via le moteur réel selon ConfigState (make_process_fn), anti double-lancement UI. app.py câble Config↔Usage↔licence. G3-D build-prep : anonymisation_gui_v6_onefile.spec (entry V6, customtkinter + modules gui_v6 en hiddenimports). Installateur Anonymisation.iss produit déjà la cible Anonymisation-Setup.exe. Aucun artefact .exe commité ; build Windows à part. Tests +14 (engine_bridge 8, config_state 6). self-test exit 0, 46 tests gui_v6, 193 tests/unit (0 régression). Moteur/V5/specs CLI intacts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
224 lines
8.5 KiB
Python
224 lines
8.5 KiB
Python
"""Onglet « Utilisation » de la GUI V6.
|
|
|
|
Sélection fichier/dossier → choix sortie → lancement via le runner (dans un
|
|
thread) → progression / journal / résumé. Aucun appel réseau au démarrage,
|
|
aucune logique de détection : tout passe par :class:`ProcessingRunner`.
|
|
|
|
Les widgets ne sont créés qu'à l'instanciation (import sûr pour ``--self-test``).
|
|
La communication thread worker → UI passe par une file drainée via ``after``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import queue
|
|
import threading
|
|
from pathlib import Path
|
|
from tkinter import filedialog
|
|
|
|
import customtkinter as ctk
|
|
|
|
from gui_v6.processing_runner import ProcessingRunner, default_output_dir
|
|
|
|
|
|
class UsageTab(ctk.CTkFrame):
|
|
def __init__(
|
|
self,
|
|
master,
|
|
runner: ProcessingRunner | None = None,
|
|
config_provider=None,
|
|
config_path: Path | None = None,
|
|
**kwargs,
|
|
):
|
|
super().__init__(master, **kwargs)
|
|
self._runner = runner or ProcessingRunner()
|
|
self._config_provider = config_provider
|
|
self._config_path = config_path
|
|
self._input_path: Path | None = None
|
|
self._output_dir: Path | None = None
|
|
self._stop_event: threading.Event | None = None
|
|
self._worker: threading.Thread | None = None
|
|
self._is_running = False
|
|
self._events: "queue.Queue[tuple]" = queue.Queue()
|
|
|
|
self._build()
|
|
self.after(120, self._drain_events)
|
|
|
|
def _build_run_runner(self) -> tuple[ProcessingRunner, Path | None]:
|
|
"""Runner d'exécution + dossier sortie selon la configuration courante.
|
|
|
|
Si une configuration est fournie, câble le moteur réel via engine_bridge ;
|
|
sinon retombe sur le runner par défaut (process_document direct).
|
|
"""
|
|
if self._config_provider is None:
|
|
return self._runner, self._output_dir
|
|
from gui_v6.engine_bridge import make_process_fn
|
|
|
|
cfg = self._config_provider()
|
|
settings = cfg.to_engine_settings(self._config_path)
|
|
runner = ProcessingRunner(process_fn=make_process_fn(settings))
|
|
output_dir = self._output_dir or getattr(cfg, "output_dir", None)
|
|
return runner, output_dir
|
|
|
|
# -- construction UI --------------------------------------------------
|
|
|
|
def _build(self) -> None:
|
|
bar = ctk.CTkFrame(self)
|
|
bar.pack(fill="x", padx=12, pady=(12, 6))
|
|
ctk.CTkButton(bar, text="Choisir un fichier…", command=self._pick_file).pack(
|
|
side="left", padx=(0, 8)
|
|
)
|
|
ctk.CTkButton(bar, text="Choisir un dossier…", command=self._pick_folder).pack(
|
|
side="left"
|
|
)
|
|
|
|
self._target_label = ctk.CTkLabel(self, text="Aucune source sélectionnée", anchor="w")
|
|
self._target_label.pack(fill="x", padx=12, pady=(0, 4))
|
|
|
|
out_bar = ctk.CTkFrame(self)
|
|
out_bar.pack(fill="x", padx=12, pady=(0, 6))
|
|
ctk.CTkButton(out_bar, text="Dossier de sortie…", command=self._pick_output).pack(
|
|
side="left", padx=(0, 8)
|
|
)
|
|
self._output_label = ctk.CTkLabel(out_bar, text="Sortie : (défaut anonymise/)", anchor="w")
|
|
self._output_label.pack(side="left")
|
|
|
|
action = ctk.CTkFrame(self)
|
|
action.pack(fill="x", padx=12, pady=(0, 6))
|
|
self._run_btn = ctk.CTkButton(action, text="Lancer", command=self._start, state="disabled")
|
|
self._run_btn.pack(side="left", padx=(0, 8))
|
|
self._stop_btn = ctk.CTkButton(action, text="Arrêter", command=self._request_stop, state="disabled")
|
|
self._stop_btn.pack(side="left")
|
|
|
|
self._progress = ctk.CTkProgressBar(self)
|
|
self._progress.set(0.0)
|
|
self._progress.pack(fill="x", padx=12, pady=(6, 4))
|
|
|
|
self._status_label = ctk.CTkLabel(self, text="Prêt.", anchor="w")
|
|
self._status_label.pack(fill="x", padx=12)
|
|
|
|
self._log = ctk.CTkTextbox(self, height=180)
|
|
self._log.pack(fill="both", expand=True, padx=12, pady=(6, 12))
|
|
self._log.configure(state="disabled")
|
|
|
|
# -- sélection --------------------------------------------------------
|
|
|
|
def _pick_file(self) -> None:
|
|
path = filedialog.askopenfilename(title="Choisir un document")
|
|
if path:
|
|
self._set_input(Path(path))
|
|
|
|
def _pick_folder(self) -> None:
|
|
path = filedialog.askdirectory(title="Choisir un dossier")
|
|
if path:
|
|
self._set_input(Path(path))
|
|
|
|
def _pick_output(self) -> None:
|
|
path = filedialog.askdirectory(title="Dossier de sortie")
|
|
if path:
|
|
self._output_dir = Path(path)
|
|
self._output_label.configure(text=f"Sortie : {self._output_dir}")
|
|
|
|
def _set_input(self, path: Path) -> None:
|
|
self._input_path = path
|
|
count = len(self._runner.discover(path))
|
|
if path.is_dir():
|
|
self._target_label.configure(text=f"Dossier : {path} · {count} document(s) détecté(s)")
|
|
else:
|
|
self._target_label.configure(text=f"Fichier : {path.name}")
|
|
self._output_label.configure(text=f"Sortie : (défaut {default_output_dir(path)})")
|
|
self._run_btn.configure(state="normal" if count > 0 else "disabled")
|
|
|
|
# -- exécution --------------------------------------------------------
|
|
|
|
def _start(self) -> None:
|
|
if self._input_path is None or self._is_running:
|
|
return
|
|
self._is_running = True
|
|
run_runner, run_output_dir = self._build_run_runner()
|
|
self._stop_event = threading.Event()
|
|
self._run_btn.configure(state="disabled")
|
|
self._stop_btn.configure(state="normal")
|
|
self._progress.set(0.0)
|
|
self._clear_log()
|
|
self._set_status("Traitement en cours…")
|
|
|
|
input_path, output_dir, stop = self._input_path, run_output_dir, self._stop_event
|
|
|
|
def work() -> None:
|
|
try:
|
|
summary = run_runner.run(
|
|
input_path,
|
|
output_dir,
|
|
on_progress=lambda done, total, name: self._events.put(("progress", done, total, name)),
|
|
on_log=lambda msg: self._events.put(("log", msg)),
|
|
stop_event=stop,
|
|
)
|
|
self._events.put(("done", summary))
|
|
except Exception as exc: # garde-fou : ne jamais laisser le thread tuer l'UI
|
|
self._events.put(("error", str(exc)))
|
|
|
|
self._worker = threading.Thread(target=work, daemon=True)
|
|
self._worker.start()
|
|
|
|
def _request_stop(self) -> None:
|
|
if self._stop_event is not None:
|
|
self._stop_event.set()
|
|
self._set_status("Arrêt demandé…")
|
|
self._stop_btn.configure(state="disabled")
|
|
|
|
# -- file d'événements worker → UI ------------------------------------
|
|
|
|
def _drain_events(self) -> None:
|
|
try:
|
|
while True:
|
|
event = self._events.get_nowait()
|
|
self._handle_event(event)
|
|
except queue.Empty:
|
|
pass
|
|
self.after(120, self._drain_events)
|
|
|
|
def _handle_event(self, event: tuple) -> None:
|
|
kind = event[0]
|
|
if kind == "progress":
|
|
_, done, total, name = event
|
|
self._progress.set(done / total if total else 0.0)
|
|
self._set_status(f"{done}/{total} — {name}")
|
|
elif kind == "log":
|
|
self._append_log(event[1])
|
|
elif kind == "done":
|
|
self._finish(event[1])
|
|
elif kind == "error":
|
|
self._append_log(f"Erreur : {event[1]}")
|
|
self._finish(None)
|
|
|
|
def _finish(self, summary) -> None:
|
|
self._is_running = False
|
|
self._stop_btn.configure(state="disabled")
|
|
self._run_btn.configure(state="normal")
|
|
if summary is None:
|
|
self._set_status("Terminé avec erreur.")
|
|
return
|
|
if summary.stopped:
|
|
self._set_status(f"Arrêté : {summary.succeeded}/{summary.total} traités.")
|
|
else:
|
|
self._progress.set(1.0)
|
|
self._set_status(
|
|
f"Terminé : {summary.succeeded} OK, {summary.failed} échec(s) sur {summary.total}."
|
|
)
|
|
|
|
# -- helpers widgets --------------------------------------------------
|
|
|
|
def _set_status(self, text: str) -> None:
|
|
self._status_label.configure(text=text)
|
|
|
|
def _clear_log(self) -> None:
|
|
self._log.configure(state="normal")
|
|
self._log.delete("1.0", "end")
|
|
self._log.configure(state="disabled")
|
|
|
|
def _append_log(self, message: str) -> None:
|
|
self._log.configure(state="normal")
|
|
self._log.insert("end", message + "\n")
|
|
self._log.see("end")
|
|
self._log.configure(state="disabled")
|