Files
anonymisation/gui_v6/tabs/tab_usage.py
Domi31tls c2c40543e5 feat(gui): add GUI V6 G2 — onglet Utilisation + runner injectable
Onglet Utilisation fonctionnel (couche présentation only) :
- processing_runner: runner testable sans display/moteur lourd, process_fn
  injectable (défaut = process_document en import paresseux), découverte
  fichier/dossier, sorties anonymise/ comme V5 (arbo préservée), progression,
  journal, résumé OK/KO, arrêt coopératif entre documents, anti double-lancement
- tabs/tab_usage: sélection fichier/dossier + nb PDF détectés, dossier sortie
  (défaut anonymise/), Lancer/Arrêter, barre de progression, statut, journal,
  résumé ; worker threadé, file d'événements drainée par after() ; aucun réseau
- app.py: onglet Utilisation câblé (placeholder G2 retiré)
- self-test: couvre processing_runner + tab_usage

Tests: +11 (runner) — discovery, sorties, échec partiel, arrêt, anti-double-run,
callbacks. self-test exit 0, 32 tests gui_v6, 179 tests/unit (0 régression).
Moteur/V5/managers/specs intacts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 18:58:10 +02:00

195 lines
7.4 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, **kwargs):
super().__init__(master, **kwargs)
self._runner = runner or ProcessingRunner()
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._events: "queue.Queue[tuple]" = queue.Queue()
self._build()
self.after(120, self._drain_events)
# -- 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._runner.is_running:
return
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, self._output_dir, self._stop_event
def work() -> None:
try:
summary = self._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._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")