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:
@@ -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
|
||||
|
||||
156
anonymisation_gui_v6_onefile.spec
Normal file
156
anonymisation_gui_v6_onefile.spec
Normal 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"),
|
||||
)
|
||||
@@ -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
63
gui_v6/config_state.py
Normal 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
184
gui_v6/engine_bridge.py
Normal 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
14
gui_v6/machine_id.py
Normal 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}"
|
||||
@@ -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
128
gui_v6/tabs/tab_config.py
Normal 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}")
|
||||
@@ -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:
|
||||
|
||||
56
tests/unit/test_gui_v6_config_state.py
Normal file
56
tests/unit/test_gui_v6_config_state.py
Normal 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
|
||||
156
tests/unit/test_gui_v6_engine_bridge.py
Normal file
156
tests/unit/test_gui_v6_engine_bridge.py
Normal 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
|
||||
Reference in New Issue
Block a user