feat(gui): charger le dictionnaires.yml externe éditable en frozen (P1-4)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 18:55:57 +02:00
parent c1c3565a0b
commit 880a75873d
3 changed files with 147 additions and 0 deletions

View File

@@ -18,6 +18,7 @@ import customtkinter as ctk
from gui_v6 import theme as theme_mod
from gui_v6 import ui_kit
from gui_v6.config_paths import resolve_user_config_path
from gui_v6.config_state import ConfigState
from gui_v6.license_client import LicenseClient, LicenseStatus
from gui_v6.machine_id import default_machine_id
@@ -70,6 +71,7 @@ class AnonymisationApp(ctk.CTk):
self._theme_name = theme_name
self._license_client = license_client or LicenseClient(resolve_portal_url())
self._config = ConfigState()
self._user_config_path = resolve_user_config_path()
self._active = "use"
self._tab_buttons: dict = {}
self._tab_frames: dict = {}
@@ -187,6 +189,7 @@ class AnonymisationApp(ctk.CTk):
self._content,
palette=p,
config_provider=lambda: self._config,
config_path=self._user_config_path,
on_theme_change=self.set_theme,
current_theme=self._theme_name,
usage_reporter=self._report_usage,

62
gui_v6/config_paths.py Normal file
View File

@@ -0,0 +1,62 @@
"""Résolution du fichier de configuration externe éditable (dictionnaires.yml).
En frozen (PyInstaller), la config doit vivre À CÔTÉ de l'exécutable pour que
l'établissement puisse l'éditer sans recompiler ; on copie la version embarquée
au premier lancement si elle est absente. En développement, on pointe directement
la config du dépôt. Aligné sur le pattern V5
(``Pseudonymisation_Gui_V5._resolve_config``), best-effort (jamais de crash).
"""
from __future__ import annotations
import logging
import shutil
import sys
from pathlib import Path
from typing import Optional
log = logging.getLogger(__name__)
_CONFIG_RELATIVE = Path("config") / "dictionnaires.yml"
def _frozen() -> bool:
return bool(getattr(sys, "frozen", False))
def _bundled_config() -> Path:
"""Config embarquée : ``_MEIPASS`` en frozen, racine du dépôt en dev."""
if _frozen():
base = Path(getattr(sys, "_MEIPASS"))
else:
base = Path(__file__).resolve().parent.parent
return base / _CONFIG_RELATIVE
def resolve_user_config_path() -> Optional[Path]:
"""Chemin du ``dictionnaires.yml`` éditable par l'utilisateur.
- dev : la config du dépôt (éditable en place) ; on ne crée jamais le fichier
(contrairement à la V5) : si absent, on renvoie ``None`` (le moteur retombe
sur sa config par défaut) ;
- frozen : ``<dossier de l'exe>/config/dictionnaires.yml`` ; copie la version
embarquée au premier lancement si absente, sans jamais écraser une config
existante (perso établissement).
Renvoie ``None`` si rien n'est résoluble (le moteur retombe alors sur sa
config runtime par défaut).
"""
if not _frozen():
bundled = _bundled_config()
return bundled if bundled.exists() else None
user_cfg = Path(sys.executable).resolve().parent / _CONFIG_RELATIVE
if user_cfg.exists():
return user_cfg
try:
user_cfg.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(_bundled_config(), user_cfg)
return user_cfg
except Exception as exc:
log.warning("copie de la configuration externe échouée (%s) : %s", user_cfg, exc)
bundled = _bundled_config()
return bundled if bundled.exists() else None

View File

@@ -0,0 +1,82 @@
"""Résolution du dictionnaires.yml externe éditable (P1-4).
Pur : on simule frozen via monkeypatch (sys.frozen / sys.executable / _MEIPASS),
aucun display, aucun modèle.
"""
from __future__ import annotations
from pathlib import Path
import gui_v6.config_paths as cp
def _make_bundle(tmp_path: Path) -> Path:
bundle = tmp_path / "bundle"
(bundle / "config").mkdir(parents=True)
(bundle / "config" / "dictionnaires.yml").write_text("whitelist_phrases: []\n", encoding="utf-8")
return bundle
def test_dev_returns_repo_config_when_present(monkeypatch):
# En dev (non frozen) : pointe la config embarquée si elle existe.
monkeypatch.setattr(cp.sys, "frozen", False, raising=False)
path = cp.resolve_user_config_path()
assert path is not None
assert path.name == "dictionnaires.yml"
assert path.exists()
def test_frozen_copies_bundle_on_first_launch(tmp_path, monkeypatch):
bundle = _make_bundle(tmp_path)
exe_dir = tmp_path / "exe"
exe_dir.mkdir()
monkeypatch.setattr(cp.sys, "frozen", True, raising=False)
monkeypatch.setattr(cp.sys, "_MEIPASS", str(bundle), raising=False)
monkeypatch.setattr(cp.sys, "executable", str(exe_dir / "Anonymisation.exe"), raising=False)
out = cp.resolve_user_config_path()
expected = exe_dir / "config" / "dictionnaires.yml"
assert out == expected
assert expected.exists() # copié depuis le bundle au 1er lancement
assert expected.read_text(encoding="utf-8") == "whitelist_phrases: []\n"
def test_frozen_keeps_existing_user_config(tmp_path, monkeypatch):
bundle = _make_bundle(tmp_path)
exe_dir = tmp_path / "exe"
(exe_dir / "config").mkdir(parents=True)
user_cfg = exe_dir / "config" / "dictionnaires.yml"
user_cfg.write_text("whitelist_phrases: [HOPITAL_LOCAL]\n", encoding="utf-8")
monkeypatch.setattr(cp.sys, "frozen", True, raising=False)
monkeypatch.setattr(cp.sys, "_MEIPASS", str(bundle), raising=False)
monkeypatch.setattr(cp.sys, "executable", str(exe_dir / "Anonymisation.exe"), raising=False)
out = cp.resolve_user_config_path()
assert out == user_cfg
# Ne JAMAIS écraser la perso établissement existante.
assert "HOPITAL_LOCAL" in out.read_text(encoding="utf-8")
def test_dev_returns_none_when_config_missing(tmp_path, monkeypatch):
# En dev, si la config embarquée est absente : on renvoie None (pas de création).
monkeypatch.setattr(cp.sys, "frozen", False, raising=False)
monkeypatch.setattr(cp, "_bundled_config", lambda: tmp_path / "absent" / "dictionnaires.yml")
assert cp.resolve_user_config_path() is None
def test_frozen_copy_failure_falls_back(tmp_path, monkeypatch):
# En frozen, si la copie échoue (ex. droits) : fallback sur la config embarquée, sans crash.
bundle = _make_bundle(tmp_path)
exe_dir = tmp_path / "exe"
exe_dir.mkdir()
monkeypatch.setattr(cp.sys, "frozen", True, raising=False)
monkeypatch.setattr(cp.sys, "_MEIPASS", str(bundle), raising=False)
monkeypatch.setattr(cp.sys, "executable", str(exe_dir / "Anonymisation.exe"), raising=False)
def _boom(*_args, **_kwargs):
raise PermissionError("accès refusé")
monkeypatch.setattr(cp.shutil, "copyfile", _boom)
out = cp.resolve_user_config_path()
assert out == cp._bundled_config()