diff --git a/engine_capabilities.py b/engine_capabilities.py new file mode 100644 index 0000000..d98bd35 --- /dev/null +++ b/engine_capabilities.py @@ -0,0 +1,130 @@ +"""Couche « capabilities moteurs » — vérité testable sur les moteurs réellement +disponibles dans l'environnement courant (dev ou build frozen). + +Un moteur n'est *disponible* que si ses dépendances chargent réellement (et, pour +CamemBERT-bio, si son modèle ONNX embarqué est présent). Cette couche est +consommée par : + - la GUI V6 (afficher / désactiver les moteurs optionnels honnêtement) ; + - le CLI (`--engines`, diagnostic des moteurs réellement embarqués). + +Objectif produit : ne jamais afficher / promettre un moteur que le build +n'embarque pas. Dans les builds frozen Windows, `edsnlp`/`spacy`/`gliner` ne sont +pas embarqués → leur spec d'import est introuvable → moteur marqué indisponible. + +Les sondes (probes) sont **légères** (`importlib.util.find_spec`, pas d'import +lourd d'edsnlp/spacy au démarrage de la GUI) et **injectables** pour les tests +(aucun modèle, aucun réseau). +""" + +from __future__ import annotations + +import importlib.util +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Dict, List, Optional + +# Probe = () -> (available: bool, reason: str) +Probe = Callable[[], "tuple[bool, str]"] + + +@dataclass(frozen=True) +class EngineCapability: + """État d'un moteur : disponible ou non, requis ou optionnel, raison courte.""" + + key: str # "camembert" | "eds" | "gliner" + label: str # libellé présentable (GUI / CLI) + available: bool + required: bool # CamemBERT-bio = moteur standard requis + reason: str # explication courte (FR), surtout si indisponible + + +# -- Métadonnées des moteurs (libellé + caractère requis) -------------------- + +_ENGINES = [ + ("camembert", "CamemBERT-bio (standard)", True), + ("eds", "EDS-Pseudo (optionnel)", False), + ("gliner", "GLiNER (optionnel)", False), +] + + +# -- Sondes par défaut (environnement réel) ---------------------------------- + +def _has_module(name: str) -> bool: + """Vrai si le module est importable sans l'importer réellement. + + `find_spec` interroge les importateurs (y compris ceux de PyInstaller en + frozen) sans exécuter le module → léger et sûr au démarrage. + """ + try: + return importlib.util.find_spec(name) is not None + except Exception: + return False + + +def _app_dir() -> Path: + """Racine des ressources (modèles) : `_MEIPASS` en frozen, sinon ce dossier.""" + if getattr(sys, "frozen", False): + return Path(getattr(sys, "_MEIPASS", Path(sys.executable).parent)) + return Path(__file__).resolve().parent + + +def _camembert_model_path() -> Path: + return _app_dir() / "models" / "camembert-bio-deid" / "onnx" / "model.onnx" + + +def _probe_camembert() -> "tuple[bool, str]": + if not _has_module("onnxruntime"): + return False, "onnxruntime non embarqué dans cette version" + if not _camembert_model_path().exists(): + return False, "modèle CamemBERT-bio ONNX absent du build" + return True, "modèle ONNX embarqué (moteur standard)" + + +def _probe_eds() -> "tuple[bool, str]": + missing = [m for m in ("edsnlp", "spacy") if not _has_module(m)] + if missing: + return False, "non embarqué dans cette version (manque : " + ", ".join(missing) + ")" + return True, "edsnlp + spacy disponibles" + + +def _probe_gliner() -> "tuple[bool, str]": + if not _has_module("gliner"): + return False, "non embarqué dans cette version (manque : gliner)" + return True, "gliner disponible" + + +def _default_probes() -> Dict[str, Probe]: + return {"camembert": _probe_camembert, "eds": _probe_eds, "gliner": _probe_gliner} + + +# -- API publique ------------------------------------------------------------ + +def capabilities_map(probes: Optional[Dict[str, Probe]] = None) -> Dict[str, EngineCapability]: + """Retourne {key: EngineCapability} pour chaque moteur connu. + + `probes` (injectable) mappe chaque clé moteur vers une sonde + `() -> (available, reason)`. Par défaut : sondes réelles de l'environnement. + Une sonde qui lève est traitée comme « indisponible » (fail-closed). + """ + probes = probes if probes is not None else _default_probes() + caps: Dict[str, EngineCapability] = {} + for key, label, required in _ENGINES: + probe = probes.get(key) + if probe is None: + available, reason = False, "aucune sonde fournie pour ce moteur" + else: + try: + available, reason = probe() + except Exception as exc: # noqa: BLE001 — fail-closed + available, reason = False, f"sonde en échec : {exc}" + caps[key] = EngineCapability( + key=key, label=label, available=bool(available), required=required, reason=str(reason) + ) + return caps + + +def available_engines(probes: Optional[Dict[str, Probe]] = None) -> List[EngineCapability]: + """Liste des moteurs réellement disponibles (ordre stable des moteurs connus).""" + caps = capabilities_map(probes) + return [caps[key] for key, _, _ in _ENGINES if caps[key].available] diff --git a/tests/unit/test_engine_capabilities.py b/tests/unit/test_engine_capabilities.py new file mode 100644 index 0000000..9cd5264 --- /dev/null +++ b/tests/unit/test_engine_capabilities.py @@ -0,0 +1,66 @@ +"""Couche 'capabilities moteurs' : vérité testable sur les moteurs disponibles. + +Un moteur n'est *disponible* que si ses dépendances (et son modèle, pour +CamemBERT) chargent réellement dans l'environnement courant. Cette couche est +consommée par la GUI (afficher/désactiver) et le CLI (`--engines`) pour que +l'application ne promette jamais un moteur qu'elle n'embarque pas. + +Sondes injectables → aucun modèle, aucun réseau dans les tests. +""" + +from __future__ import annotations + +import engine_capabilities as ec + + +def _probes(camembert=True, eds=False, gliner=False): + return { + "camembert": lambda: (camembert, "ok" if camembert else "modèle absent"), + "eds": lambda: (eds, "ok" if eds else "edsnlp non embarqué"), + "gliner": lambda: (gliner, "ok" if gliner else "gliner non embarqué"), + } + + +def test_capabilities_map_reads_injected_probes(): + caps = ec.capabilities_map(probes=_probes(camembert=True, eds=False, gliner=False)) + assert set(caps) == {"camembert", "eds", "gliner"} + assert caps["camembert"].available is True + assert caps["eds"].available is False + assert caps["gliner"].available is False + + +def test_camembert_required_others_optional(): + caps = ec.capabilities_map(probes=_probes()) + assert caps["camembert"].required is True + assert caps["eds"].required is False + assert caps["gliner"].required is False + + +def test_reason_surfaced_when_unavailable(): + caps = ec.capabilities_map(probes=_probes(eds=False)) + assert "edsnlp" in caps["eds"].reason # explication présentable à l'utilisateur + # un moteur disponible expose aussi une raison non vide + assert caps["camembert"].reason + + +def test_available_engines_filters_unavailable(): + avail = ec.available_engines(probes=_probes(camembert=True, eds=True, gliner=False)) + keys = {c.key for c in avail} + assert keys == {"camembert", "eds"} + + +def test_labels_are_human_readable(): + caps = ec.capabilities_map(probes=_probes()) + assert "CamemBERT" in caps["camembert"].label + assert "EDS" in caps["eds"].label + assert "GLiNER" in caps["gliner"].label + + +def test_default_probes_run_without_crash_and_are_consistent(): + """Les sondes par défaut (find_spec + fichier modèle) ne crashent pas et + renvoient un booléen + une raison non vide pour chaque moteur.""" + caps = ec.capabilities_map() # sondes réelles de l'environnement + assert set(caps) == {"camembert", "eds", "gliner"} + for cap in caps.values(): + assert isinstance(cap.available, bool) + assert isinstance(cap.reason, str) and cap.reason