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:
|
def _self_test() -> int:
|
||||||
"""Importe les modules du socle GUI V6 sans créer de fenêtre."""
|
"""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 import ( # noqa: F401
|
||||||
from gui_v6.tabs import tab_about, tab_usage # 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.
|
# Sanity check des contrats publics du socle.
|
||||||
assert hasattr(app, "AnonymisationApp")
|
assert hasattr(app, "AnonymisationApp")
|
||||||
@@ -25,7 +34,11 @@ def _self_test() -> int:
|
|||||||
assert hasattr(license_client, "LicenseStatus")
|
assert hasattr(license_client, "LicenseStatus")
|
||||||
assert hasattr(license_store, "LicenseStore")
|
assert hasattr(license_store, "LicenseStore")
|
||||||
assert hasattr(processing_runner, "ProcessingRunner")
|
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_about, "AboutTab")
|
||||||
|
assert hasattr(tab_config, "ConfigTab")
|
||||||
assert hasattr(tab_usage, "UsageTab")
|
assert hasattr(tab_usage, "UsageTab")
|
||||||
print("GUI V6 self-test OK")
|
print("GUI V6 self-test OK")
|
||||||
return 0
|
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
|
import customtkinter as ctk
|
||||||
|
|
||||||
from gui_v6 import theme as theme_mod
|
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.license_client import LicenseClient, LicenseStatus
|
||||||
from gui_v6.tabs.tab_about import AboutTab
|
from gui_v6.tabs.tab_about import AboutTab
|
||||||
|
from gui_v6.tabs.tab_config import ConfigTab
|
||||||
from gui_v6.tabs.tab_usage import UsageTab
|
from gui_v6.tabs.tab_usage import UsageTab
|
||||||
|
|
||||||
_TABS = ("Utilisation", "Configuration", "À propos")
|
_TABS = ("Utilisation", "Configuration", "À propos")
|
||||||
@@ -39,6 +41,9 @@ class AnonymisationApp(ctk.CTk):
|
|||||||
self._license_client = license_client or LicenseClient("http://localhost")
|
self._license_client = license_client or LicenseClient("http://localhost")
|
||||||
status = self._safe_local_status()
|
status = self._safe_local_status()
|
||||||
|
|
||||||
|
# État de configuration partagé entre Configuration et Utilisation.
|
||||||
|
self._config = ConfigState()
|
||||||
|
|
||||||
self.title("Pseudonymisation de vos documents")
|
self.title("Pseudonymisation de vos documents")
|
||||||
self.geometry("960x640")
|
self.geometry("960x640")
|
||||||
|
|
||||||
@@ -78,20 +83,22 @@ class AnonymisationApp(ctk.CTk):
|
|||||||
for name in _TABS:
|
for name in _TABS:
|
||||||
tabview.add(name)
|
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._usage.pack(fill="both", expand=True)
|
||||||
|
|
||||||
self._about = AboutTab(
|
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)
|
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
|
@staticmethod
|
||||||
def _banner_text(status: LicenseStatus) -> str:
|
def _banner_text(status: LicenseStatus) -> str:
|
||||||
return f"Licence : {status.status}"
|
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
|
Affiche le statut licence et permet l'activation par clef (via
|
||||||
licence est indisponible (bandeau d'information, pas d'erreur bloquante).
|
``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``).
|
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 __version__ as GUI_VERSION
|
||||||
from gui_v6 import theme as theme_mod
|
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 = {
|
_STATUS_LABELS = {
|
||||||
"active": "Licence active",
|
"active": "Licence active",
|
||||||
@@ -31,11 +33,11 @@ def _build_info() -> str:
|
|||||||
try:
|
try:
|
||||||
import build_info # type: ignore
|
import build_info # type: ignore
|
||||||
|
|
||||||
version = getattr(build_info, "VERSION", "?")
|
commit = getattr(build_info, "BUILD_COMMIT", "?")
|
||||||
commit = getattr(build_info, "COMMIT", "?")
|
branch = getattr(build_info, "BUILD_BRANCH", "?")
|
||||||
return f"Moteur {version} ({commit})"
|
return f"Build {commit} ({branch})"
|
||||||
except Exception:
|
except Exception:
|
||||||
return "Moteur : information de build indisponible"
|
return "Build : information indisponible"
|
||||||
|
|
||||||
|
|
||||||
class AboutTab(ctk.CTkFrame):
|
class AboutTab(ctk.CTkFrame):
|
||||||
@@ -44,10 +46,14 @@ class AboutTab(ctk.CTkFrame):
|
|||||||
master,
|
master,
|
||||||
status: Optional[LicenseStatus] = None,
|
status: Optional[LicenseStatus] = None,
|
||||||
theme_name: str = theme_mod.DEFAULT_THEME,
|
theme_name: str = theme_mod.DEFAULT_THEME,
|
||||||
|
license_client: Optional[LicenseClient] = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(master, **kwargs)
|
super().__init__(master, **kwargs)
|
||||||
self._theme_name = theme_name
|
self._theme_name = theme_name
|
||||||
|
self._client = license_client
|
||||||
|
self._machine_id = default_machine_id()
|
||||||
|
self._status = status or LicenseStatus.none()
|
||||||
|
|
||||||
ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
self,
|
self,
|
||||||
@@ -55,17 +61,33 @@ class AboutTab(ctk.CTkFrame):
|
|||||||
font=ctk.CTkFont(size=18, weight="bold"),
|
font=ctk.CTkFont(size=18, weight="bold"),
|
||||||
).pack(anchor="w", padx=16, pady=(16, 4))
|
).pack(anchor="w", padx=16, pady=(16, 4))
|
||||||
|
|
||||||
ctk.CTkLabel(self, text=f"Interface V6 — {GUI_VERSION}").pack(
|
ctk.CTkLabel(self, text=f"Interface V6 — {GUI_VERSION}").pack(anchor="w", padx=16)
|
||||||
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))
|
||||||
ctk.CTkLabel(self, text=_build_info()).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._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:
|
def set_status(self, status: LicenseStatus) -> None:
|
||||||
|
self._status = status
|
||||||
label = _STATUS_LABELS.get(status.status, status.status)
|
label = _STATUS_LABELS.get(status.status, status.status)
|
||||||
text = f"État licence : {label}"
|
text = f"État licence : {label}"
|
||||||
if status.expires_at:
|
if status.expires_at:
|
||||||
@@ -74,3 +96,25 @@ class AboutTab(ctk.CTkFrame):
|
|||||||
text += f"\n{status.message}"
|
text += f"\n{status.message}"
|
||||||
color = theme_mod.status_color(self._theme_name, status.status)
|
color = theme_mod.status_color(self._theme_name, status.status)
|
||||||
self._status_label.configure(text=text, text_color=color)
|
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):
|
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)
|
super().__init__(master, **kwargs)
|
||||||
self._runner = runner or ProcessingRunner()
|
self._runner = runner or ProcessingRunner()
|
||||||
|
self._config_provider = config_provider
|
||||||
|
self._config_path = config_path
|
||||||
self._input_path: Path | None = None
|
self._input_path: Path | None = None
|
||||||
self._output_dir: Path | None = None
|
self._output_dir: Path | None = None
|
||||||
self._stop_event: threading.Event | None = None
|
self._stop_event: threading.Event | None = None
|
||||||
self._worker: threading.Thread | None = None
|
self._worker: threading.Thread | None = None
|
||||||
|
self._is_running = False
|
||||||
self._events: "queue.Queue[tuple]" = queue.Queue()
|
self._events: "queue.Queue[tuple]" = queue.Queue()
|
||||||
|
|
||||||
self._build()
|
self._build()
|
||||||
self.after(120, self._drain_events)
|
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 --------------------------------------------------
|
# -- construction UI --------------------------------------------------
|
||||||
|
|
||||||
def _build(self) -> None:
|
def _build(self) -> None:
|
||||||
@@ -105,8 +131,10 @@ class UsageTab(ctk.CTkFrame):
|
|||||||
# -- exécution --------------------------------------------------------
|
# -- exécution --------------------------------------------------------
|
||||||
|
|
||||||
def _start(self) -> None:
|
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
|
return
|
||||||
|
self._is_running = True
|
||||||
|
run_runner, run_output_dir = self._build_run_runner()
|
||||||
self._stop_event = threading.Event()
|
self._stop_event = threading.Event()
|
||||||
self._run_btn.configure(state="disabled")
|
self._run_btn.configure(state="disabled")
|
||||||
self._stop_btn.configure(state="normal")
|
self._stop_btn.configure(state="normal")
|
||||||
@@ -114,11 +142,11 @@ class UsageTab(ctk.CTkFrame):
|
|||||||
self._clear_log()
|
self._clear_log()
|
||||||
self._set_status("Traitement en cours…")
|
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:
|
def work() -> None:
|
||||||
try:
|
try:
|
||||||
summary = self._runner.run(
|
summary = run_runner.run(
|
||||||
input_path,
|
input_path,
|
||||||
output_dir,
|
output_dir,
|
||||||
on_progress=lambda done, total, name: self._events.put(("progress", done, total, name)),
|
on_progress=lambda done, total, name: self._events.put(("progress", done, total, name)),
|
||||||
@@ -164,6 +192,7 @@ class UsageTab(ctk.CTkFrame):
|
|||||||
self._finish(None)
|
self._finish(None)
|
||||||
|
|
||||||
def _finish(self, summary) -> None:
|
def _finish(self, summary) -> None:
|
||||||
|
self._is_running = False
|
||||||
self._stop_btn.configure(state="disabled")
|
self._stop_btn.configure(state="disabled")
|
||||||
self._run_btn.configure(state="normal")
|
self._run_btn.configure(state="normal")
|
||||||
if summary is None:
|
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