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

@@ -16,8 +16,17 @@ import sys
def _self_test() -> int:
"""Importe les modules du socle GUI V6 sans créer de fenêtre."""
from gui_v6 import app, license_client, license_store, processing_runner, theme # noqa: F401
from gui_v6.tabs import tab_about, tab_usage # noqa: F401
from gui_v6 import ( # noqa: F401
app,
config_state,
engine_bridge,
license_client,
license_store,
machine_id,
processing_runner,
theme,
)
from gui_v6.tabs import tab_about, tab_config, tab_usage # noqa: F401
# Sanity check des contrats publics du socle.
assert hasattr(app, "AnonymisationApp")
@@ -25,7 +34,11 @@ def _self_test() -> int:
assert hasattr(license_client, "LicenseStatus")
assert hasattr(license_store, "LicenseStore")
assert hasattr(processing_runner, "ProcessingRunner")
assert hasattr(engine_bridge, "make_process_fn")
assert hasattr(config_state, "ConfigState")
assert hasattr(machine_id, "default_machine_id")
assert hasattr(tab_about, "AboutTab")
assert hasattr(tab_config, "ConfigTab")
assert hasattr(tab_usage, "UsageTab")
print("GUI V6 self-test OK")
return 0

View File

@@ -0,0 +1,156 @@
# PyInstaller spec — GUI V6 (build-prep G3-D).
#
# Produit `Anonymisation.exe` (V6), source de l'installateur Inno
# `installer/Anonymisation.iss` qui génère la cible finale `Anonymisation-Setup.exe`.
#
# Entrée directe : Pseudonymisation_Gui_V6.py (expose main() + --self-test).
# Ne construit AUCUN artefact ici : la génération réelle se fait sur Windows.
import os
from pathlib import Path
block_cipher = None
project_dir = Path(globals().get("SPECPATH", os.getcwd())).resolve()
def _data_entry(relative_path: str, target_dir: str | None = None):
src = project_dir / relative_path
if not src.exists():
return None
return (str(src), target_dir or relative_path)
datas = []
for relative_path, target_dir in [
("config", "config"),
("data/bdpm", "data/bdpm"),
("data/finess", "data/finess"),
("data/insee", "data/insee"),
("models/camembert-bio-deid/onnx", "models/camembert-bio-deid/onnx"),
("detectors", "detectors"),
("scripts", "scripts"),
("assets", "assets"),
]:
entry = _data_entry(relative_path, target_dir)
if entry is not None:
datas.append(entry)
for relative_path in [
"data/stopwords_manuels.txt",
"data/villes_blacklist.txt",
"data/dpi_labels_blacklist.txt",
"data/companion_blacklist.txt",
]:
entry = _data_entry(relative_path, "data")
if entry is not None:
datas.append(entry)
hiddenimports = [
# Entrée + package GUI V6
"Pseudonymisation_Gui_V6",
"gui_v6",
"gui_v6.app",
"gui_v6.theme",
"gui_v6.license_client",
"gui_v6.license_store",
"gui_v6.machine_id",
"gui_v6.engine_bridge",
"gui_v6.config_state",
"gui_v6.processing_runner",
"gui_v6.tabs",
"gui_v6.tabs.tab_about",
"gui_v6.tabs.tab_config",
"gui_v6.tabs.tab_usage",
# UI customtkinter
"customtkinter",
"darkdetect",
# Réseau licence
"requests",
# Moteur + modules support (inchangés vs V5)
"anonymizer_core_refactored_onnx",
"admin_mode",
"admin_rules",
"config_defaults",
"profile_defaults",
"gui_batch_paths",
"manual_masking",
"pdf_mask_designer",
"format_converter",
"ner_manager_onnx",
"camembert_ner_manager",
"eds_pseudo_manager",
"gliner_manager",
"vlm_manager",
"build_info",
"doctr",
"doctr.io",
"doctr.models",
"doctr.models.detection",
"doctr.models.recognition",
"cv2",
"torchvision",
"edsnlp",
"edsnlp.pipes",
"edsnlp.pipes.ner",
"edsnlp.pipes.ner.pseudo",
"spacy",
"spacy.lang.fr",
"gliner",
"onnxruntime",
"transformers",
"tokenizers",
"torch",
"pdfplumber",
"fitz",
"PIL",
"yaml",
"loguru",
"regex",
"optimum",
"optimum.onnxruntime",
"optimum.pipelines",
"optimum.modeling_base",
"optimum.exporters.onnx",
]
a = Analysis(
[str(project_dir / "Pseudonymisation_Gui_V6.py")],
pathex=[str(project_dir)],
datas=datas,
hiddenimports=hiddenimports,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
splash = Splash(
str(project_dir / "assets" / "splash.png"),
binaries=a.binaries,
datas=a.datas,
text_pos=(60, 195),
text_size=10,
text_color="white",
minify_script=True,
always_on_top=False,
)
exe = EXE(
pyz,
a.scripts,
splash,
splash.binaries,
a.binaries,
a.zipfiles,
a.datas,
[],
name="Anonymisation",
debug=False,
strip=False,
upx=False,
console=False,
icon=str(project_dir / "assets" / "icons" / "app.ico"),
)

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:

View File

@@ -0,0 +1,56 @@
"""Tests de l'état de configuration G3-B (profils/options résolus sans fichiers réels)."""
from __future__ import annotations
from pathlib import Path
from gui_v6.config_state import ConfigState, default_profile_key, list_profile_keys
from gui_v6.engine_bridge import EngineSettings
def test_to_engine_settings_defaults():
settings = ConfigState().to_engine_settings(config_path=Path("/tmp/c.yml"))
assert isinstance(settings, EngineSettings)
assert settings.make_vector_redaction is False
assert settings.also_make_raster_burn is True
assert settings.use_local_ner is True
assert settings.config_path == Path("/tmp/c.yml")
def test_to_engine_settings_custom():
state = ConfigState(
profile="oncologie",
raster_burn=False,
use_local_ner=False,
enable_gliner=True,
ogc_label="OCG",
)
settings = state.to_engine_settings()
assert settings.also_make_raster_burn is False
assert settings.use_local_ner is False
assert settings.enable_gliner is True
assert settings.profile == "oncologie"
assert settings.ogc_label == "OCG"
def test_list_profile_keys_injected():
keys = list_profile_keys(lister=lambda: {"b": {}, "a": {}})
assert keys == ["a", "b"]
def test_list_profile_keys_failure_returns_empty():
def boom():
raise RuntimeError("pas de profils")
assert list_profile_keys(lister=boom) == []
def test_default_profile_key_injected():
assert default_profile_key(getter=lambda: "defaut") == "defaut"
def test_default_profile_key_failure_returns_none():
def boom():
raise RuntimeError("ko")
assert default_profile_key(getter=boom) is None

View File

@@ -0,0 +1,156 @@
"""Tests du pont moteur G3-A : kwargs corrects, managers lazy, engine injecté.
Aucun vrai manager, aucun modèle, aucun réseau : tout est injecté via factories.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from gui_v6.engine_bridge import (
EngineSettings,
ManagerState,
NerManagers,
build_engine_kwargs,
make_process_fn,
)
class FakeManager:
def __init__(self, name):
self.name = name
def _counting_factories(counter, fail_camembert=False):
def camembert():
counter["camembert"] += 1
if fail_camembert:
raise RuntimeError("modèle absent")
return FakeManager("camembert")
def eds():
counter["eds"] += 1
return FakeManager("eds")
def gliner():
counter["gliner"] += 1
return FakeManager("gliner")
return {"camembert": camembert, "eds": eds, "gliner": gliner}
# -- kwargs ----------------------------------------------------------------
def test_kwargs_defaults_v5_like():
settings = EngineSettings(config_path=Path("/tmp/cfg.yml"), ogc_label="OCG")
kwargs = build_engine_kwargs(settings, managers=None)
assert kwargs["make_vector_redaction"] is False
assert kwargs["also_make_raster_burn"] is True
assert kwargs["config_path"] == Path("/tmp/cfg.yml")
assert kwargs["ogc_label"] == "OCG"
# Sans managers : pas de NER.
assert kwargs["use_hf"] is False
def test_kwargs_with_loaded_managers():
settings = EngineSettings(enable_eds=True, enable_gliner=True)
counter = {"camembert": 0, "eds": 0, "gliner": 0}
managers = NerManagers(settings, factories=_counting_factories(counter))
managers.ensure_loaded()
kwargs = build_engine_kwargs(settings, managers)
assert kwargs["use_hf"] is True
assert kwargs["camembert_manager"].name == "camembert"
assert kwargs["ner_manager"].name == "eds"
assert kwargs["gliner_manager"].name == "gliner"
def test_kwargs_ner_disabled():
settings = EngineSettings(use_local_ner=False)
counter = {"camembert": 0, "eds": 0, "gliner": 0}
managers = NerManagers(settings, factories=_counting_factories(counter))
managers.ensure_loaded()
kwargs = build_engine_kwargs(settings, managers)
assert kwargs["use_hf"] is False
assert counter["camembert"] == 0 # NER désactivé : rien chargé
# -- lazy loading ----------------------------------------------------------
def test_managers_not_loaded_on_init():
settings = EngineSettings()
counter = {"camembert": 0, "eds": 0, "gliner": 0}
NerManagers(settings, factories=_counting_factories(counter))
# Aucune factory appelée à la construction.
assert counter == {"camembert": 0, "eds": 0, "gliner": 0}
def test_managers_load_once_and_state():
settings = EngineSettings(enable_eds=True)
counter = {"camembert": 0, "eds": 0, "gliner": 0}
managers = NerManagers(settings, factories=_counting_factories(counter))
assert managers.state == ManagerState.NOT_LOADED
assert managers.ensure_loaded() == ManagerState.READY
assert managers.ensure_loaded() == ManagerState.READY # idempotent
assert counter["camembert"] == 1 # chargé une seule fois
assert counter["eds"] == 1
assert counter["gliner"] == 0 # non activé
def test_managers_unavailable_when_camembert_fails():
settings = EngineSettings()
counter = {"camembert": 0, "eds": 0, "gliner": 0}
managers = NerManagers(settings, factories=_counting_factories(counter, fail_camembert=True))
assert managers.ensure_loaded() == ManagerState.UNAVAILABLE
assert managers.use_hf is False
def test_optional_manager_failure_is_tolerated():
settings = EngineSettings(enable_gliner=True)
def factories():
def camembert():
return FakeManager("camembert")
def gliner():
raise RuntimeError("gliner ko")
def eds():
return FakeManager("eds")
return {"camembert": camembert, "eds": eds, "gliner": gliner}
managers = NerManagers(settings, factories=factories())
assert managers.ensure_loaded() == ManagerState.READY # gliner ko ne bloque pas
assert managers.use_hf is True
# -- make_process_fn -------------------------------------------------------
def test_process_fn_calls_engine_with_kwargs(tmp_path):
settings = EngineSettings()
counter = {"camembert": 0, "eds": 0, "gliner": 0}
managers = NerManagers(settings, factories=_counting_factories(counter))
captured = {}
def fake_engine(doc_path, out_dir, **kwargs):
captured["doc"] = doc_path
captured["out"] = out_dir
captured["kwargs"] = kwargs
return {"status": "ok"}
fn = make_process_fn(settings, managers=managers, engine=fake_engine)
# Avant tout traitement, aucun manager chargé.
assert counter["camembert"] == 0
result = fn(tmp_path / "doc.pdf", tmp_path / "out")
assert result == {"status": "ok"}
assert captured["doc"] == tmp_path / "doc.pdf"
assert captured["kwargs"]["make_vector_redaction"] is False
assert captured["kwargs"]["also_make_raster_burn"] is True
assert captured["kwargs"]["use_hf"] is True
assert captured["kwargs"]["camembert_manager"].name == "camembert"
# Le chargement n'a eu lieu qu'à l'appel de process_fn.
assert counter["camembert"] == 1