From 23557d2cf94ecc6205c3180f23bd2e8069f765d7 Mon Sep 17 00:00:00 2001 From: Domi31tls Date: Fri, 12 Jun 2026 10:53:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(gui):=20GUI=20V6=20G3=20=E2=80=94=20c?= =?UTF-8?q?=C3=A2blage=20moteur,=20Configuration,=20licence=20UI,=20build-?= =?UTF-8?q?prep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Pseudonymisation_Gui_V6.py | 17 ++- anonymisation_gui_v6_onefile.spec | 156 ++++++++++++++++++++ gui_v6/app.py | 23 +-- gui_v6/config_state.py | 63 ++++++++ gui_v6/engine_bridge.py | 184 ++++++++++++++++++++++++ gui_v6/machine_id.py | 14 ++ gui_v6/tabs/tab_about.py | 72 ++++++++-- gui_v6/tabs/tab_config.py | 128 +++++++++++++++++ gui_v6/tabs/tab_usage.py | 37 ++++- tests/unit/test_gui_v6_config_state.py | 56 ++++++++ tests/unit/test_gui_v6_engine_bridge.py | 156 ++++++++++++++++++++ 11 files changed, 878 insertions(+), 28 deletions(-) create mode 100644 anonymisation_gui_v6_onefile.spec create mode 100644 gui_v6/config_state.py create mode 100644 gui_v6/engine_bridge.py create mode 100644 gui_v6/machine_id.py create mode 100644 gui_v6/tabs/tab_config.py create mode 100644 tests/unit/test_gui_v6_config_state.py create mode 100644 tests/unit/test_gui_v6_engine_bridge.py diff --git a/Pseudonymisation_Gui_V6.py b/Pseudonymisation_Gui_V6.py index db68821..4d0dc5a 100644 --- a/Pseudonymisation_Gui_V6.py +++ b/Pseudonymisation_Gui_V6.py @@ -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 diff --git a/anonymisation_gui_v6_onefile.spec b/anonymisation_gui_v6_onefile.spec new file mode 100644 index 0000000..b0a89a9 --- /dev/null +++ b/anonymisation_gui_v6_onefile.spec @@ -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"), +) diff --git a/gui_v6/app.py b/gui_v6/app.py index f0c2aa3..ad23946 100644 --- a/gui_v6/app.py +++ b/gui_v6/app.py @@ -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}" diff --git a/gui_v6/config_state.py b/gui_v6/config_state.py new file mode 100644 index 0000000..19065ae --- /dev/null +++ b/gui_v6/config_state.py @@ -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 diff --git a/gui_v6/engine_bridge.py b/gui_v6/engine_bridge.py new file mode 100644 index 0000000..b5f3b51 --- /dev/null +++ b/gui_v6/engine_bridge.py @@ -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 diff --git a/gui_v6/machine_id.py b/gui_v6/machine_id.py new file mode 100644 index 0000000..1a162c5 --- /dev/null +++ b/gui_v6/machine_id.py @@ -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}" diff --git a/gui_v6/tabs/tab_about.py b/gui_v6/tabs/tab_about.py index 1b9f817..04bc238 100644 --- a/gui_v6/tabs/tab_about.py +++ b/gui_v6/tabs/tab_about.py @@ -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)) diff --git a/gui_v6/tabs/tab_config.py b/gui_v6/tabs/tab_config.py new file mode 100644 index 0000000..205f3a8 --- /dev/null +++ b/gui_v6/tabs/tab_config.py @@ -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}") diff --git a/gui_v6/tabs/tab_usage.py b/gui_v6/tabs/tab_usage.py index bf40e2e..53e68ee 100644 --- a/gui_v6/tabs/tab_usage.py +++ b/gui_v6/tabs/tab_usage.py @@ -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: diff --git a/tests/unit/test_gui_v6_config_state.py b/tests/unit/test_gui_v6_config_state.py new file mode 100644 index 0000000..2c40f66 --- /dev/null +++ b/tests/unit/test_gui_v6_config_state.py @@ -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 diff --git a/tests/unit/test_gui_v6_engine_bridge.py b/tests/unit/test_gui_v6_engine_bridge.py new file mode 100644 index 0000000..351e7f4 --- /dev/null +++ b/tests/unit/test_gui_v6_engine_bridge.py @@ -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