From 880a75873d169ab2c4867d546d33fdc27e12a3cf Mon Sep 17 00:00:00 2001 From: Domi31tls Date: Mon, 29 Jun 2026 18:55:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(gui):=20charger=20le=20dictionnaires.yml?= =?UTF-8?q?=20externe=20=C3=A9ditable=20en=20frozen=20(P1-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- gui_v6/app.py | 3 + gui_v6/config_paths.py | 62 +++++++++++++++++++ tests/unit/test_gui_v6_config_paths.py | 82 ++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 gui_v6/config_paths.py create mode 100644 tests/unit/test_gui_v6_config_paths.py diff --git a/gui_v6/app.py b/gui_v6/app.py index 4cf306c..a49e4c8 100644 --- a/gui_v6/app.py +++ b/gui_v6/app.py @@ -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, diff --git a/gui_v6/config_paths.py b/gui_v6/config_paths.py new file mode 100644 index 0000000..745afa5 --- /dev/null +++ b/gui_v6/config_paths.py @@ -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 : ``/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 diff --git a/tests/unit/test_gui_v6_config_paths.py b/tests/unit/test_gui_v6_config_paths.py new file mode 100644 index 0000000..2e8da3a --- /dev/null +++ b/tests/unit/test_gui_v6_config_paths.py @@ -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()