feat(gui): GUI V6 G3 — câblage moteur, Configuration, licence UI, build-prep

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>
This commit is contained in:
2026-06-12 10:53:47 +02:00
parent c2c40543e5
commit 23557d2cf9
11 changed files with 878 additions and 28 deletions

View File

@@ -15,8 +15,10 @@ from typing import Optional
import customtkinter as ctk
from gui_v6 import theme as theme_mod
from gui_v6.config_state import ConfigState
from gui_v6.license_client import LicenseClient, LicenseStatus
from gui_v6.tabs.tab_about import AboutTab
from gui_v6.tabs.tab_config import ConfigTab
from gui_v6.tabs.tab_usage import UsageTab
_TABS = ("Utilisation", "Configuration", "À propos")
@@ -39,6 +41,9 @@ class AnonymisationApp(ctk.CTk):
self._license_client = license_client or LicenseClient("http://localhost")
status = self._safe_local_status()
# État de configuration partagé entre Configuration et Utilisation.
self._config = ConfigState()
self.title("Pseudonymisation de vos documents")
self.geometry("960x640")
@@ -78,20 +83,22 @@ class AnonymisationApp(ctk.CTk):
for name in _TABS:
tabview.add(name)
self._usage = UsageTab(tabview.tab("Utilisation"))
self._config_tab = ConfigTab(tabview.tab("Configuration"), state=self._config)
self._config_tab.pack(fill="both", expand=True)
self._usage = UsageTab(
tabview.tab("Utilisation"), config_provider=lambda: self._config
)
self._usage.pack(fill="both", expand=True)
self._about = AboutTab(
tabview.tab("À propos"), status=status, theme_name=self._theme_name
tabview.tab("À propos"),
status=status,
theme_name=self._theme_name,
license_client=self._license_client,
)
self._about.pack(fill="both", expand=True)
# Placeholder G3.
ctk.CTkLabel(
tabview.tab("Configuration"),
text="Onglet Configuration — disponible au lot G3.",
).pack(padx=16, pady=16, anchor="w")
@staticmethod
def _banner_text(status: LicenseStatus) -> str:
return f"Licence : {status.status}"

63
gui_v6/config_state.py Normal file
View File

@@ -0,0 +1,63 @@
"""État de configuration de la GUI V6 (G3-B), testable sans display ni fichiers.
Détient les options simples exposées par l'onglet Configuration et sait produire
des :class:`EngineSettings`. La résolution des profils s'appuie sur
``profile_defaults`` (jamais modifié) avec injection possible pour les tests.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, List, Optional
from gui_v6.engine_bridge import EngineSettings
@dataclass
class ConfigState:
"""Options de configuration utilisateur (réglages simples G3-B)."""
profile: Optional[str] = None
raster_burn: bool = True
use_local_ner: bool = True
enable_eds: bool = False
enable_gliner: bool = False
output_dir: Optional[Path] = None
ogc_label: Optional[str] = None
def to_engine_settings(self, config_path: Optional[Path] = None) -> EngineSettings:
return EngineSettings(
make_vector_redaction=False,
also_make_raster_burn=self.raster_burn,
config_path=config_path,
use_local_ner=self.use_local_ner,
enable_eds=self.enable_eds,
enable_gliner=self.enable_gliner,
ogc_label=self.ogc_label,
profile=self.profile,
)
def list_profile_keys(lister: Optional[Callable[[], dict]] = None) -> List[str]:
"""Liste triée des clés de profils. Best-effort : [] si indisponible."""
if lister is None:
from profile_defaults import list_effective_profiles
lister = list_effective_profiles
try:
return sorted(lister().keys())
except Exception:
return []
def default_profile_key(getter: Optional[Callable[[], str]] = None) -> Optional[str]:
"""Clé du profil par défaut, ou None si indisponible."""
if getter is None:
from profile_defaults import get_default_profile_key
getter = get_default_profile_key
try:
return getter()
except Exception:
return None

184
gui_v6/engine_bridge.py Normal file
View File

@@ -0,0 +1,184 @@
"""Pont GUI V6 → moteur d'anonymisation (G3-A).
Construit les kwargs d'appel du moteur (``process_document``) au plus proche de
la V5 / du CLI de production, et charge les managers NER **paresseusement** :
- aucun manager n'est importé ni instancié à l'import de ce module ;
- le chargement réel n'a lieu qu'au premier traitement (``ensure_loaded``) ;
- les factories sont injectables pour les tests (aucun modèle, aucun réseau).
Mapping moteur (identique au CLI validé `scripts/anonymize_cli.py`) :
- ``camembert_manager`` ← CamembertNerManager (NER local principal)
- ``ner_manager`` ← EdsPseudoManager (optionnel)
- ``gliner_manager`` ← GlinerManager (optionnel)
- ``use_hf`` ← True si au moins un manager NER est chargé
Aucune logique de détection ici : on orchestre uniquement.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Any, Callable, Optional
ProcessFn = Callable[[Path, Path], dict]
ManagerFactory = Callable[[], Any]
class ManagerState(str, Enum):
NOT_LOADED = "not_loaded"
LOADING = "loading"
READY = "ready"
UNAVAILABLE = "unavailable"
@dataclass
class EngineSettings:
"""Réglages d'appel moteur exposés par l'onglet Configuration."""
make_vector_redaction: bool = False
also_make_raster_burn: bool = True
config_path: Optional[Path] = None
use_local_ner: bool = True
enable_eds: bool = False
enable_gliner: bool = False
ogc_label: Optional[str] = None
profile: Optional[str] = None
def _default_factories() -> dict[str, ManagerFactory]:
"""Factories par défaut : import paresseux, instanciation + load réels.
Définies dans une fonction pour qu'aucun manager ne soit importé à l'import
de ce module.
"""
def camembert() -> Any:
from camembert_ner_manager import CamembertNerManager
manager = CamembertNerManager()
manager.load()
return manager
def eds() -> Any:
from eds_pseudo_manager import EdsPseudoManager
manager = EdsPseudoManager()
manager.load()
return manager
def gliner() -> Any:
from gliner_manager import GlinerManager
manager = GlinerManager()
manager.load()
return manager
return {"camembert": camembert, "eds": eds, "gliner": gliner}
class NerManagers:
"""Conteneur de managers NER à chargement paresseux."""
def __init__(
self,
settings: EngineSettings,
factories: Optional[dict[str, ManagerFactory]] = None,
) -> None:
self._settings = settings
self._factories = factories if factories is not None else _default_factories()
self._camembert: Any = None
self._eds: Any = None
self._gliner: Any = None
self._state = ManagerState.NOT_LOADED
@property
def state(self) -> ManagerState:
return self._state
@property
def use_hf(self) -> bool:
return bool(self._camembert or self._eds or self._gliner)
def as_kwargs(self) -> dict:
return {
"ner_manager": self._eds,
"gliner_manager": self._gliner,
"camembert_manager": self._camembert,
}
def ensure_loaded(self) -> ManagerState:
"""Charge les managers requis si nécessaire. Idempotent, sans crash."""
if not self._settings.use_local_ner:
self._state = ManagerState.NOT_LOADED
return self._state
if self._state == ManagerState.READY:
return self._state
self._state = ManagerState.LOADING
try:
# CamemBERT-bio est le NER local principal (obligatoire si NER actif).
self._camembert = self._factories["camembert"]()
except Exception:
self._state = ManagerState.UNAVAILABLE
return self._state
if self._settings.enable_eds:
try:
self._eds = self._factories["eds"]()
except Exception:
self._eds = None # optionnel : absence tolérée
if self._settings.enable_gliner:
try:
self._gliner = self._factories["gliner"]()
except Exception:
self._gliner = None # optionnel : absence tolérée
self._state = ManagerState.READY
return self._state
def build_engine_kwargs(
settings: EngineSettings, managers: Optional[NerManagers] = None
) -> dict:
"""Construit le dict de kwargs passé au moteur."""
kwargs: dict = {
"make_vector_redaction": settings.make_vector_redaction,
"also_make_raster_burn": settings.also_make_raster_burn,
"config_path": settings.config_path,
"ogc_label": settings.ogc_label,
}
if managers is not None and settings.use_local_ner:
kwargs.update(managers.as_kwargs())
kwargs["use_hf"] = managers.use_hf
else:
kwargs["use_hf"] = False
return kwargs
def make_process_fn(
settings: EngineSettings,
managers: Optional[NerManagers] = None,
engine: Optional[Callable[..., dict]] = None,
) -> ProcessFn:
"""Retourne un ``process_fn(doc, out_dir)`` câblé au moteur.
``engine`` est injectable pour les tests ; par défaut, import paresseux de
``process_document`` (aucun chargement du moteur à l'import de ce module).
"""
managers = managers if managers is not None else NerManagers(settings)
def process_fn(doc_path: Path, out_dir: Path) -> dict:
if settings.use_local_ner:
managers.ensure_loaded()
kwargs = build_engine_kwargs(settings, managers)
run = engine
if run is None:
from anonymizer_core_refactored_onnx import process_document
run = process_document
return run(doc_path, out_dir, **kwargs)
return process_fn

14
gui_v6/machine_id.py Normal file
View File

@@ -0,0 +1,14 @@
"""Identifiant de poste pour l'activation de licence (G3-C).
Dérivé de l'adresse matérielle (``uuid.getnode``). Stable sur une même machine,
non sensible (pas un secret). Aucun appel réseau.
"""
from __future__ import annotations
import uuid
def default_machine_id() -> str:
"""Retourne un identifiant de poste hexadécimal stable (12 caractères)."""
return f"{uuid.getnode():012x}"

View File

@@ -1,7 +1,8 @@
"""Onglet « À propos » : version, et état de la licence.
"""Onglet « À propos » : version, build, et activation/état de la licence (G3-C).
Affiche le statut licence fourni par le client/stub. Dégrade proprement si la
licence est indisponible (bandeau d'information, pas d'erreur bloquante).
Affiche le statut licence et permet l'activation par clef (via
``LicenseClient.activate``) et la vérification (``check``). Aucun appel réseau au
démarrage : seul l'état local est lu. Aucun token n'est journalisé.
Les widgets ne sont créés qu'à l'instanciation (import sûr pour ``--self-test``).
"""
@@ -13,7 +14,8 @@ import customtkinter as ctk
from gui_v6 import __version__ as GUI_VERSION
from gui_v6 import theme as theme_mod
from gui_v6.license_client import LicenseStatus
from gui_v6.license_client import LicenseClient, LicenseStatus
from gui_v6.machine_id import default_machine_id
_STATUS_LABELS = {
"active": "Licence active",
@@ -31,11 +33,11 @@ def _build_info() -> str:
try:
import build_info # type: ignore
version = getattr(build_info, "VERSION", "?")
commit = getattr(build_info, "COMMIT", "?")
return f"Moteur {version} ({commit})"
commit = getattr(build_info, "BUILD_COMMIT", "?")
branch = getattr(build_info, "BUILD_BRANCH", "?")
return f"Build {commit} ({branch})"
except Exception:
return "Moteur : information de build indisponible"
return "Build : information indisponible"
class AboutTab(ctk.CTkFrame):
@@ -44,10 +46,14 @@ class AboutTab(ctk.CTkFrame):
master,
status: Optional[LicenseStatus] = None,
theme_name: str = theme_mod.DEFAULT_THEME,
license_client: Optional[LicenseClient] = None,
**kwargs,
) -> None:
super().__init__(master, **kwargs)
self._theme_name = theme_name
self._client = license_client
self._machine_id = default_machine_id()
self._status = status or LicenseStatus.none()
ctk.CTkLabel(
self,
@@ -55,17 +61,33 @@ class AboutTab(ctk.CTkFrame):
font=ctk.CTkFont(size=18, weight="bold"),
).pack(anchor="w", padx=16, pady=(16, 4))
ctk.CTkLabel(self, text=f"Interface V6 — {GUI_VERSION}").pack(
anchor="w", padx=16
)
ctk.CTkLabel(self, text=_build_info()).pack(anchor="w", padx=16, pady=(0, 12))
ctk.CTkLabel(self, text=f"Interface V6 — {GUI_VERSION}").pack(anchor="w", padx=16)
ctk.CTkLabel(self, text=_build_info()).pack(anchor="w", padx=16, pady=(0, 4))
ctk.CTkLabel(self, text=f"Poste : {self._machine_id}").pack(anchor="w", padx=16, pady=(0, 12))
self._status_label = ctk.CTkLabel(self, text="", anchor="w")
self._status_label = ctk.CTkLabel(self, text="", anchor="w", justify="left")
self._status_label.pack(anchor="w", padx=16, pady=(0, 8))
self.set_status(status or LicenseStatus.none())
# Bloc activation licence
block = ctk.CTkFrame(self)
block.pack(fill="x", padx=16, pady=(0, 12))
ctk.CTkLabel(block, text="Activation par clef :").pack(side="left", padx=(8, 8), pady=8)
self._key_entry = ctk.CTkEntry(block, width=260, placeholder_text="Clef d'activation")
self._key_entry.pack(side="left", padx=(0, 8), pady=8)
self._activate_btn = ctk.CTkButton(block, text="Activer", command=self._activate)
self._activate_btn.pack(side="left", padx=(0, 8), pady=8)
self._check_btn = ctk.CTkButton(block, text="Vérifier", command=self._check)
self._check_btn.pack(side="left", padx=(0, 8), pady=8)
if self._client is None:
# Pas de client : activation désactivée, mode dev/bêta.
self._activate_btn.configure(state="disabled")
self._check_btn.configure(state="disabled")
self.set_status(self._status)
def set_status(self, status: LicenseStatus) -> None:
self._status = status
label = _STATUS_LABELS.get(status.status, status.status)
text = f"État licence : {label}"
if status.expires_at:
@@ -74,3 +96,25 @@ class AboutTab(ctk.CTkFrame):
text += f"\n{status.message}"
color = theme_mod.status_color(self._theme_name, status.status)
self._status_label.configure(text=text, text_color=color)
# -- actions licence --------------------------------------------------
def _activate(self) -> None:
if self._client is None:
return
token = self._key_entry.get().strip()
if not token:
return
status = self._client.activate(token, self._machine_id)
self.set_status(status)
# Ne jamais conserver le jeton saisi dans l'UI après usage.
self._key_entry.delete(0, "end")
def _check(self) -> None:
if self._client is None:
return
ref = self._status.license_ref
if not ref:
self.set_status(LicenseStatus.none("Aucune licence à vérifier"))
return
self.set_status(self._client.check(ref, self._machine_id))

128
gui_v6/tabs/tab_config.py Normal file
View File

@@ -0,0 +1,128 @@
"""Onglet « Configuration » de la GUI V6 (G3-B).
Édite un :class:`ConfigState` partagé : profil métier, raster burn, NER local,
dossier de sortie. Affiche l'état des managers NER. Les options sensibles ne sont
visibles/éditables qu'en mode admin (``admin_mode.is_admin``). Aucune logique de
détection : on édite seulement l'état lu par l'onglet Utilisation.
Les widgets ne sont créés qu'à l'instanciation (import sûr pour ``--self-test``).
"""
from __future__ import annotations
from pathlib import Path
from tkinter import filedialog
import customtkinter as ctk
from gui_v6.config_state import ConfigState, default_profile_key, list_profile_keys
def _is_admin() -> bool:
try:
from admin_mode import is_admin
return bool(is_admin())
except Exception:
return False
class ConfigTab(ctk.CTkFrame):
def __init__(self, master, state: ConfigState | None = None, **kwargs):
super().__init__(master, **kwargs)
self._state = state if state is not None else ConfigState()
self._admin = _is_admin()
self._build()
@property
def state(self) -> ConfigState:
return self._state
def _build(self) -> None:
ctk.CTkLabel(
self, text="Configuration", font=ctk.CTkFont(size=16, weight="bold")
).pack(anchor="w", padx=16, pady=(16, 8))
# Profil métier
profiles = list_profile_keys()
current = self._state.profile or default_profile_key() or (profiles[0] if profiles else "")
self._state.profile = current or None
row = ctk.CTkFrame(self)
row.pack(fill="x", padx=16, pady=4)
ctk.CTkLabel(row, text="Profil :").pack(side="left", padx=(0, 8))
self._profile_menu = ctk.CTkOptionMenu(
row,
values=profiles or ["(aucun profil)"],
command=self._on_profile,
)
if current:
self._profile_menu.set(current)
self._profile_menu.pack(side="left")
# Options simples
self._raster = ctk.CTkCheckBox(
self, text="Caviardage raster (burn)", command=self._on_raster
)
self._raster.select() if self._state.raster_burn else self._raster.deselect()
self._raster.pack(anchor="w", padx=16, pady=4)
self._ner = ctk.CTkCheckBox(
self, text="NER local actif", command=self._on_ner
)
self._ner.select() if self._state.use_local_ner else self._ner.deselect()
self._ner.pack(anchor="w", padx=16, pady=4)
# Dossier de sortie
out_row = ctk.CTkFrame(self)
out_row.pack(fill="x", padx=16, pady=4)
ctk.CTkButton(out_row, text="Dossier de sortie…", command=self._pick_output).pack(
side="left", padx=(0, 8)
)
self._output_label = ctk.CTkLabel(
out_row, text=str(self._state.output_dir or "(défaut anonymise/)")
)
self._output_label.pack(side="left")
# Options admin-only
if self._admin:
ctk.CTkLabel(self, text="Options avancées (admin)").pack(
anchor="w", padx=16, pady=(12, 4)
)
self._gliner = ctk.CTkCheckBox(
self, text="GLiNER (vote croisé)", command=self._on_gliner
)
self._gliner.pack(anchor="w", padx=16, pady=2)
self._eds = ctk.CTkCheckBox(
self, text="EDS-Pseudo", command=self._on_eds
)
self._eds.pack(anchor="w", padx=16, pady=2)
# État des managers
self._managers_label = ctk.CTkLabel(self, text="Managers NER : non chargé", anchor="w")
self._managers_label.pack(anchor="w", padx=16, pady=(12, 16))
# -- callbacks --------------------------------------------------------
def _on_profile(self, value: str) -> None:
self._state.profile = value
def _on_raster(self) -> None:
self._state.raster_burn = bool(self._raster.get())
def _on_ner(self) -> None:
self._state.use_local_ner = bool(self._ner.get())
def _on_gliner(self) -> None:
self._state.enable_gliner = bool(self._gliner.get())
def _on_eds(self) -> None:
self._state.enable_eds = bool(self._eds.get())
def _pick_output(self) -> None:
path = filedialog.askdirectory(title="Dossier de sortie")
if path:
self._state.output_dir = Path(path)
self._output_label.configure(text=str(self._state.output_dir))
def set_managers_state(self, state_text: str) -> None:
self._managers_label.configure(text=f"Managers NER : {state_text}")

View File

@@ -21,18 +21,44 @@ from gui_v6.processing_runner import ProcessingRunner, default_output_dir
class UsageTab(ctk.CTkFrame):
def __init__(self, master, runner: ProcessingRunner | None = None, **kwargs):
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:
@@ -105,8 +131,10 @@ class UsageTab(ctk.CTkFrame):
# -- exécution --------------------------------------------------------
def _start(self) -> None:
if self._input_path is None or self._runner.is_running:
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")
@@ -114,11 +142,11 @@ class UsageTab(ctk.CTkFrame):
self._clear_log()
self._set_status("Traitement en cours…")
input_path, output_dir, stop = self._input_path, self._output_dir, self._stop_event
input_path, output_dir, stop = self._input_path, run_output_dir, self._stop_event
def work() -> None:
try:
summary = self._runner.run(
summary = run_runner.run(
input_path,
output_dir,
on_progress=lambda done, total, name: self._events.put(("progress", done, total, name)),
@@ -164,6 +192,7 @@ class UsageTab(ctk.CTkFrame):
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: