Compare commits
8 Commits
c1c3565a0b
...
675e328d8c
| Author | SHA1 | Date | |
|---|---|---|---|
| 675e328d8c | |||
| 4813f9439e | |||
| ee1f86d55e | |||
| 3a981eb15a | |||
| d3189d5bb7 | |||
| 1d65d42430 | |||
| 416b347d7f | |||
| 880a75873d |
@@ -5679,7 +5679,7 @@ def process_pdf(
|
||||
|
||||
# Log OCR dans l'audit
|
||||
if ocr_used:
|
||||
anon.audit.insert(0, PiiHit(page=-1, kind="OCR_USED", original="docTR", placeholder=""))
|
||||
anon.audit.insert(0, PiiHit(page=-1, kind="OCR_USED", original="OnnxTR", placeholder=""))
|
||||
|
||||
# Filtrer les faux positifs hospitaliers
|
||||
if _HOSPITAL_FILTER_AVAILABLE:
|
||||
|
||||
@@ -0,0 +1,900 @@
|
||||
# GUI V6 → bêta — Plan 1c : honnêteté UI Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Supprimer les derniers contrôles UI trompeurs / non fonctionnels de GUI V6 pour la bêta, et garantir le comportement honnête attendu d'un outil médical (config établissement réellement chargée, avertissement avant de dégrader la détection, erreurs localisables, échange de config par email recâblé).
|
||||
|
||||
**Architecture :** Chaque correctif est isolé derrière une **fonction pure testable sans display ni modèle** (résolution de chemin, décision de confirmation, formatage de message, export/import JSON), le widget se contentant de câbler cette fonction. Aucune logique de détection n'est touchée (le gating catégories est déjà livré en Plan 1b). On suit le pattern V5 existant pour la config externe (`Pseudonymisation_Gui_V5._resolve_config`) et le format d'échange JSON consommé par `scripts/merge_params.merge_params`.
|
||||
|
||||
**Tech Stack :** Python 3.10-3.12, customtkinter (CTk), tkinter.messagebox/filedialog, pytest (tests purs + `pytest.importorskip("customtkinter")` pour les rares smokes widget), PyYAML.
|
||||
|
||||
**Portée (spec chantier D — `docs/superpowers/specs/2026-06-25-gui-v6-beta-prod-design.md`) :**
|
||||
- Task 1 — **P1-4** : `config_path` (dictionnaires.yml) réellement résolu et chargé en frozen.
|
||||
- Task 2 — **Confirmation NER off** (décision Dom 2026-06-29) : dialogue avant de tomber en regex-only.
|
||||
- Task 3 — **P1-5** : erreurs / quarantaine localisables + bouton « Ouvrir le dossier ».
|
||||
- Task 4 — **P1-3** : Import / Export de configuration recâblés (workflow email V5).
|
||||
- Task 5 — **P1-1** : dropzone honnête (cliquable) — DnD natif différé.
|
||||
- Task 6 (optionnel) — **P1-6** : validation d'inscriptibilité du dossier de sortie.
|
||||
- Task 7 (optionnel) — **P1-11** : provenance audit `original="docTR"` → `"OnnxTR"`.
|
||||
|
||||
**Hors portée (différé, acté) :** DnD natif `tkinterdnd2` (lib native tkdnd à bundler — repoussé pour ne pas alourdir le build torch-free du Plan 3, voir Task 5) ; P2-1/P2-2 (progression/cartes format) traités au mieux mais non bloquants.
|
||||
|
||||
**Convention de commit :** un commit atomique par task (core+test ensemble), préfixe conventionnel, finir par `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`. Brancher sur `feature/q1-quarantine-mvp`, pousser sur `gitea` (jamais `git add -A` — stager par chemin).
|
||||
|
||||
**Commande de test de référence :**
|
||||
`.venv/bin/python -m pytest tests/unit/test_gui_v6_*.py -q` (+ gate `synthetic_regression` avant push).
|
||||
|
||||
---
|
||||
|
||||
### Task 1 : P1-4 — Config externe `dictionnaires.yml` réellement résolue en frozen
|
||||
|
||||
**Problème :** `AnonymisationApp` crée `UsageTab` **sans** `config_path` (`gui_v6/app.py:186-193`), donc `to_engine_settings(self._config_path)` reçoit `None` (`tab_usage.py:176`). En frozen, le `dictionnaires.yml` éditable à côté de l'EXE n'est jamais chargé → personnalisations établissement ignorées.
|
||||
|
||||
**Files:**
|
||||
- Create: `gui_v6/config_paths.py`
|
||||
- Create: `tests/unit/test_gui_v6_config_paths.py`
|
||||
- Modify: `gui_v6/app.py` (import + `__init__` + appel `UsageTab(...)`)
|
||||
|
||||
- [ ] **Step 1 : Écrire les tests (échouent — module absent)**
|
||||
|
||||
`tests/unit/test_gui_v6_config_paths.py` :
|
||||
|
||||
```python
|
||||
"""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")
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer → vérifier l'échec**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_config_paths.py -q`
|
||||
Expected: FAIL (`ModuleNotFoundError: No module named 'gui_v6.config_paths'`).
|
||||
|
||||
- [ ] **Step 3 : Implémenter `gui_v6/config_paths.py`**
|
||||
|
||||
```python
|
||||
"""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 shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
_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) ;
|
||||
- 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:
|
||||
bundled = _bundled_config()
|
||||
return bundled if bundled.exists() else None
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Câbler dans `gui_v6/app.py`**
|
||||
|
||||
En tête (après les imports `gui_v6`), ajouter :
|
||||
|
||||
```python
|
||||
from gui_v6.config_paths import resolve_user_config_path
|
||||
```
|
||||
|
||||
Dans `AnonymisationApp.__init__`, après `self._config = ConfigState()` :
|
||||
|
||||
```python
|
||||
self._user_config_path = resolve_user_config_path()
|
||||
```
|
||||
|
||||
Dans `_create_tab`, branche `"use"`, ajouter le kwarg à `UsageTab(...)` :
|
||||
|
||||
```python
|
||||
return UsageTab(
|
||||
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,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Lancer → vérifier le succès + non-régression socle**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_config_paths.py tests/unit/test_gui_v6_app_shell.py -q`
|
||||
Expected: PASS (3 nouveaux + app_shell inchangés).
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add gui_v6/config_paths.py gui_v6/app.py tests/unit/test_gui_v6_config_paths.py
|
||||
git commit -m "feat(gui): charger le dictionnaires.yml externe éditable en frozen (P1-4)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2 : Confirmation avant désactivation du NER (regex-only) — décision Dom 2026-06-29
|
||||
|
||||
**Problème :** Couper le toggle CamemBERT-bio (`tab_config.py:393-395`, callback `_on_ner:893`) bascule en anonymisation **regex seule** sans avertissement. Sur un outil médical, on confirme explicitement.
|
||||
|
||||
**Files:**
|
||||
- Modify: `gui_v6/tabs/tab_config.py` (constante + helper pur + `_on_ner`)
|
||||
- Create: `tests/unit/test_gui_v6_ner_confirm.py`
|
||||
|
||||
- [ ] **Step 1 : Écrire les tests (échouent — symbole absent)**
|
||||
|
||||
`tests/unit/test_gui_v6_ner_confirm.py` :
|
||||
|
||||
```python
|
||||
"""Confirmation avant de désactiver le NER (regex-only) — outil médical.
|
||||
|
||||
Pur : la décision est isolée dans ``confirm_ner_disable(asker)`` ; ``asker`` est
|
||||
injecté (pas de messagebox réel, pas de display).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from gui_v6.tabs.tab_config import NER_DISABLE_WARNING, confirm_ner_disable
|
||||
|
||||
|
||||
def test_confirm_true_when_user_accepts():
|
||||
assert confirm_ner_disable(lambda: True) is True
|
||||
|
||||
|
||||
def test_confirm_false_when_user_declines():
|
||||
assert confirm_ner_disable(lambda: False) is False
|
||||
|
||||
|
||||
def test_warning_text_is_explicit_for_medical_use():
|
||||
txt = NER_DISABLE_WARNING.lower()
|
||||
# L'avertissement DOIT nommer la dégradation : règles/regex + risque noms.
|
||||
assert "règles" in txt or "regex" in txt
|
||||
assert "nom" in txt
|
||||
assert "recommand" in txt # « fortement recommandé »
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer → vérifier l'échec**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_ner_confirm.py -q`
|
||||
Expected: FAIL (`ImportError: cannot import name 'confirm_ner_disable'`).
|
||||
|
||||
- [ ] **Step 3 : Ajouter la constante + le helper pur dans `tab_config.py`**
|
||||
|
||||
Après les imports (sous `from tkinter import filedialog, messagebox`), au niveau module :
|
||||
|
||||
```python
|
||||
NER_DISABLE_WARNING = (
|
||||
"Vous allez désactiver le moteur d'intelligence artificielle "
|
||||
"(CamemBERT-bio).\n\n"
|
||||
"Sans lui, la détection des NOMS de personnes repose uniquement sur des "
|
||||
"règles (expressions régulières) : des noms peuvent rester EN CLAIR dans "
|
||||
"les documents.\n\n"
|
||||
"Pour un usage médical, garder ce moteur activé est fortement recommandé.\n\n"
|
||||
"Confirmer la désactivation ?"
|
||||
)
|
||||
|
||||
|
||||
def confirm_ner_disable(asker) -> bool:
|
||||
"""Décision de désactivation du NER.
|
||||
|
||||
``asker`` est une fonction ``() -> bool`` (ex. ``messagebox.askyesno``),
|
||||
injectée pour rester testable sans display. Retourne True si l'utilisateur
|
||||
CONFIRME la désactivation (regex-only), False sinon.
|
||||
"""
|
||||
return bool(asker())
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Câbler `_on_ner` (intercepter le passage à OFF)**
|
||||
|
||||
Remplacer `_on_ner` (`tab_config.py:893-894`) par :
|
||||
|
||||
```python
|
||||
def _on_ner(self) -> None:
|
||||
new_value = self._tog_ner.get()
|
||||
if not new_value:
|
||||
confirmed = confirm_ner_disable(
|
||||
lambda: messagebox.askyesno(
|
||||
"Moteur de détection", NER_DISABLE_WARNING, icon="warning"
|
||||
)
|
||||
)
|
||||
if not confirmed:
|
||||
# Refus : rétablir l'affichage du switch et garder le NER actif.
|
||||
self._tog_ner.var.set(True)
|
||||
self._state.use_local_ner = True
|
||||
return
|
||||
self._state.use_local_ner = new_value
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Lancer → vérifier le succès**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_ner_confirm.py tests/unit/test_gui_v6_config_mockup_sections.py -q`
|
||||
Expected: PASS (3 nouveaux + sections inchangées).
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add gui_v6/tabs/tab_config.py tests/unit/test_gui_v6_ner_confirm.py
|
||||
git commit -m "feat(gui): confirmation explicite avant anonymisation regex-only (NER off)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3 : P1-5 — Erreurs / quarantaine localisables + « Ouvrir le dossier »
|
||||
|
||||
**Problème :** En fin de run, `_finish` (`tab_usage.py:271-283`) n'indique pas OÙ trouver les documents livrés ni que les documents en échec/quarantaine ne sont **pas** anonymisés. Le testeur reste bloqué.
|
||||
|
||||
**Files:**
|
||||
- Create: `gui_v6/fsutil.py` (ouverture cross-plateforme du dossier)
|
||||
- Create: `tests/unit/test_gui_v6_result_hint.py`
|
||||
- Modify: `gui_v6/tabs/tab_usage.py` (helper pur `failure_hint`, mémoriser le dossier de sortie, afficher hint + bouton)
|
||||
|
||||
- [ ] **Step 1 : Écrire les tests (échouent)**
|
||||
|
||||
`tests/unit/test_gui_v6_result_hint.py` :
|
||||
|
||||
```python
|
||||
"""Message d'aide localisant les documents non livrés (P1-5) + ouverture dossier.
|
||||
|
||||
Pur : pas de display. ``failure_hint`` formate un texte ; ``open_in_file_manager``
|
||||
dispatch vers la bonne commande OS (monkeypatchée).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import gui_v6.fsutil as fsutil
|
||||
from gui_v6.processing_runner import RunSummary
|
||||
from gui_v6.tabs.tab_usage import failure_hint
|
||||
|
||||
|
||||
def test_no_hint_when_all_ok():
|
||||
s = RunSummary(total=3, succeeded=3, failed=0)
|
||||
assert failure_hint(s, Path("/out")) is None
|
||||
|
||||
|
||||
def test_hint_when_failures_mentions_output_dir():
|
||||
s = RunSummary(total=3, succeeded=2, failed=1)
|
||||
hint = failure_hint(s, Path("/out/anonymise"))
|
||||
assert hint is not None
|
||||
assert "/out/anonymise" in hint
|
||||
# Honnêteté : préciser que les échecs ne sont PAS anonymisés.
|
||||
assert "pas" in hint.lower()
|
||||
|
||||
|
||||
def test_hint_when_stopped():
|
||||
s = RunSummary(total=3, succeeded=1, failed=0, stopped=True)
|
||||
assert failure_hint(s, Path("/out")) is not None
|
||||
|
||||
|
||||
def test_no_hint_without_output_dir():
|
||||
s = RunSummary(total=1, succeeded=0, failed=1)
|
||||
assert failure_hint(s, None) is None
|
||||
|
||||
|
||||
def test_open_in_file_manager_dispatches(monkeypatch):
|
||||
calls = {}
|
||||
monkeypatch.setattr(fsutil.sys, "platform", "linux")
|
||||
monkeypatch.setattr(fsutil.subprocess, "Popen", lambda args, **k: calls.setdefault("args", args))
|
||||
fsutil.open_in_file_manager(Path("/out"))
|
||||
assert calls["args"][0] == "xdg-open"
|
||||
assert calls["args"][1] == "/out"
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer → vérifier l'échec**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_result_hint.py -q`
|
||||
Expected: FAIL (`ModuleNotFoundError: gui_v6.fsutil` / `failure_hint` absent).
|
||||
|
||||
- [ ] **Step 3 : Implémenter `gui_v6/fsutil.py`**
|
||||
|
||||
```python
|
||||
"""Ouverture du gestionnaire de fichiers sur un dossier (cross-plateforme).
|
||||
|
||||
Best-effort : ne lève jamais (un échec d'ouverture ne doit pas casser l'UI).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def open_in_file_manager(path) -> None:
|
||||
"""Ouvre ``path`` dans l'explorateur de fichiers du système."""
|
||||
target = str(Path(path))
|
||||
try:
|
||||
if sys.platform.startswith("win"):
|
||||
import os
|
||||
|
||||
os.startfile(target) # type: ignore[attr-defined] # noqa: S606
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.Popen(["open", target])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", target])
|
||||
except Exception:
|
||||
pass
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Ajouter `failure_hint` (pur) dans `tab_usage.py`**
|
||||
|
||||
Au niveau module (après `_HELP_USAGE`) :
|
||||
|
||||
```python
|
||||
def failure_hint(summary, output_dir) -> str | None:
|
||||
"""Message localisant les documents livrés, ou None si run nominal.
|
||||
|
||||
Honnête : les documents en échec / quarantaine ne sont PAS anonymisés et
|
||||
ne sont donc pas écrits dans le dossier de sortie.
|
||||
"""
|
||||
if summary is None or output_dir is None:
|
||||
return None
|
||||
if summary.failed == 0 and not getattr(summary, "stopped", False):
|
||||
return None
|
||||
return (
|
||||
f"Documents anonymisés écrits dans : {output_dir}\n"
|
||||
"Les documents en échec ou en quarantaine ne sont PAS anonymisés et "
|
||||
"n'ont pas été écrits."
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Mémoriser le dossier de sortie effectif + afficher hint/bouton**
|
||||
|
||||
Dans `_start`, après `run_runner, run_output_dir = self._build_run_runner()` :
|
||||
|
||||
```python
|
||||
self._last_output_dir = run_output_dir or default_output_dir(self._input_path)
|
||||
```
|
||||
|
||||
Dans `_finish`, à la fin (après `self._show_results(summary)`), ajouter l'appel :
|
||||
|
||||
```python
|
||||
self._show_failure_hint(summary)
|
||||
```
|
||||
|
||||
Puis ajouter la méthode (après `_show_results`) :
|
||||
|
||||
```python
|
||||
def _show_failure_hint(self, summary) -> None:
|
||||
hint = failure_hint(summary, getattr(self, "_last_output_dir", None))
|
||||
if hint is None:
|
||||
return
|
||||
p = self._p
|
||||
row = ctk.CTkFrame(self._rsec, fg_color="transparent")
|
||||
row.pack(fill="x", padx=16, pady=(0, 12))
|
||||
ctk.CTkLabel(
|
||||
row, text=hint, text_color=p["text_dim"], font=ui_kit.font(11),
|
||||
anchor="w", justify="left",
|
||||
).pack(side="left", fill="x", expand=True)
|
||||
ui_kit.secondary_button(
|
||||
row, p, "📂 Ouvrir le dossier",
|
||||
command=lambda: open_in_file_manager(self._last_output_dir),
|
||||
).pack(side="right")
|
||||
```
|
||||
|
||||
Ajouter en tête de `tab_usage.py` :
|
||||
|
||||
```python
|
||||
from gui_v6.fsutil import open_in_file_manager
|
||||
```
|
||||
|
||||
- [ ] **Step 6 : Lancer → vérifier le succès**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_result_hint.py tests/unit/test_gui_v6_processing_runner.py -q`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7 : Commit**
|
||||
|
||||
```bash
|
||||
git add gui_v6/fsutil.py gui_v6/tabs/tab_usage.py tests/unit/test_gui_v6_result_hint.py
|
||||
git commit -m "feat(gui): localiser les documents livrés + bouton ouvrir le dossier (P1-5)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4 : P1-3 — Import / Export de configuration recâblés (workflow email V5)
|
||||
|
||||
**Problème :** Les boutons Partage sont des `_mockup_button` désactivés « (à venir) » (`tab_config.py:847,855`), alors que l'échange de config par email (export JSON → `merge_params` → renvoi YAML) est un workflow produit clé (CLAUDE.md). En V6, les listes whitelist/blacklist vivent dans le **profil actif** (`_pro_term_lists`, `tab_config.py:721-722,747-748`).
|
||||
|
||||
**Décision de modèle (à confirmer en revue) :** l'export produit le format V5 consommé par `merge_params` : `{"version", "date_export", "whitelist_phrases", "blacklist_force_mask_terms"}`, alimenté par les listes du **profil actuellement chargé dans l'éditeur** (`self._pro_term_lists`). L'import fusionne le JSON reçu dans le `dictionnaires.yml` utilisateur résolu (Task 1) via `merge_params`.
|
||||
|
||||
**Files:**
|
||||
- Create: `gui_v6/config_share.py` (sérialisation pure export/import)
|
||||
- Create: `tests/unit/test_gui_v6_config_share.py`
|
||||
- Modify: `gui_v6/tabs/tab_config.py` (`_build_partage` : vrais boutons + handlers ; passer `config_path`)
|
||||
- Modify: `gui_v6/app.py` (passer `config_path=self._user_config_path` à `ConfigTab`)
|
||||
|
||||
- [ ] **Step 1 : Écrire les tests (échouent)**
|
||||
|
||||
`tests/unit/test_gui_v6_config_share.py` :
|
||||
|
||||
```python
|
||||
"""Export / import de configuration (P1-3) — format compatible merge_params.
|
||||
|
||||
Pur : sérialisation/désérialisation et fusion, sans display ni filedialog.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from gui_v6.config_share import build_export_payload, import_config_file
|
||||
|
||||
|
||||
def test_export_payload_has_v5_schema():
|
||||
payload = build_export_payload(
|
||||
whitelist=["Dr Métier", "Service ORL"],
|
||||
blacklist=["DUPONT"],
|
||||
version="2026.06.29",
|
||||
)
|
||||
assert payload["version"] == "2026.06.29"
|
||||
assert "date_export" in payload
|
||||
assert payload["whitelist_phrases"] == ["Dr Métier", "Service ORL"]
|
||||
assert payload["blacklist_force_mask_terms"] == ["DUPONT"]
|
||||
|
||||
|
||||
def test_export_payload_is_json_serializable():
|
||||
payload = build_export_payload(whitelist=["A"], blacklist=["B"], version="1")
|
||||
json.dumps(payload) # ne doit pas lever
|
||||
|
||||
|
||||
def test_import_merges_into_user_config(tmp_path, monkeypatch):
|
||||
# Config utilisateur YAML minimale.
|
||||
cfg = tmp_path / "dictionnaires.yml"
|
||||
cfg.write_text("whitelist_phrases: [Existant]\n", encoding="utf-8")
|
||||
# JSON reçu d'un établissement.
|
||||
incoming = tmp_path / "recu.json"
|
||||
incoming.write_text(
|
||||
json.dumps({
|
||||
"version": "1", "date_export": "2026-06-29",
|
||||
"whitelist_phrases": ["Nouveau"],
|
||||
"blacklist_force_mask_terms": ["MASQUERMOI"],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
added = import_config_file(incoming, cfg)
|
||||
assert added is True
|
||||
import yaml
|
||||
merged = yaml.safe_load(cfg.read_text(encoding="utf-8"))
|
||||
assert "Existant" in merged["whitelist_phrases"]
|
||||
assert "Nouveau" in merged["whitelist_phrases"]
|
||||
assert "MASQUERMOI" in merged["blacklist"]["force_mask_terms"]
|
||||
|
||||
|
||||
def test_import_returns_false_when_nothing_new(tmp_path):
|
||||
cfg = tmp_path / "dictionnaires.yml"
|
||||
cfg.write_text("whitelist_phrases: [Deja]\n", encoding="utf-8")
|
||||
incoming = tmp_path / "recu.json"
|
||||
incoming.write_text(
|
||||
json.dumps({"whitelist_phrases": ["Deja"], "blacklist_force_mask_terms": []}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert import_config_file(incoming, cfg) is False
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer → vérifier l'échec**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_config_share.py -q`
|
||||
Expected: FAIL (`ModuleNotFoundError: gui_v6.config_share`).
|
||||
|
||||
- [ ] **Step 3 : Implémenter `gui_v6/config_share.py`**
|
||||
|
||||
```python
|
||||
"""Échange de configuration par fichier JSON (workflow email V5, P1-3).
|
||||
|
||||
- ``build_export_payload`` : produit le dict V5 (consommé par
|
||||
``scripts/merge_params.merge_params``) à partir des listes du profil courant ;
|
||||
- ``import_config_file`` : fusionne un JSON reçu dans le ``dictionnaires.yml``
|
||||
utilisateur, sans écraser l'existant (réutilise ``merge_params``).
|
||||
|
||||
Aucune dépendance à un widget : testable en pur Python.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
EXPORT_VERSION = "1"
|
||||
|
||||
|
||||
def build_export_payload(
|
||||
whitelist: Iterable[str], blacklist: Iterable[str], version: str = EXPORT_VERSION
|
||||
) -> dict:
|
||||
"""Construit la charge utile d'export au format consommé par merge_params."""
|
||||
return {
|
||||
"version": version,
|
||||
"date_export": datetime.now(timezone.utc).isoformat(),
|
||||
"whitelist_phrases": [str(t) for t in whitelist],
|
||||
"blacklist_force_mask_terms": [str(t) for t in blacklist],
|
||||
}
|
||||
|
||||
|
||||
def _yaml_lists(config_path: Path) -> tuple[set, set]:
|
||||
import yaml
|
||||
|
||||
cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
wl = set(cfg.get("whitelist_phrases", []) or [])
|
||||
bl = set((cfg.get("blacklist", {}) or {}).get("force_mask_terms", []) or [])
|
||||
return wl, bl
|
||||
|
||||
|
||||
def import_config_file(json_path, config_path) -> bool:
|
||||
"""Fusionne ``json_path`` dans ``config_path`` (YAML). Retourne True si la
|
||||
config a changé, False si rien de nouveau.
|
||||
|
||||
Fusion autonome (union des listes, jamais d'écrasement) — volontairement
|
||||
SANS dépendance à ``scripts/merge_params`` (non bundlé en frozen). Même
|
||||
sémantique : ``whitelist_phrases`` et ``blacklist.force_mask_terms``.
|
||||
"""
|
||||
import json
|
||||
import yaml
|
||||
|
||||
json_path = Path(json_path)
|
||||
config_path = Path(config_path)
|
||||
before_wl, before_bl = _yaml_lists(config_path)
|
||||
|
||||
data = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
incoming_wl = {str(t).strip() for t in data.get("whitelist_phrases", []) if str(t).strip()}
|
||||
incoming_bl = {str(t).strip() for t in data.get("blacklist_force_mask_terms", []) if str(t).strip()}
|
||||
|
||||
after_wl = before_wl | incoming_wl
|
||||
after_bl = before_bl | incoming_bl
|
||||
if after_wl == before_wl and after_bl == before_bl:
|
||||
return False
|
||||
|
||||
cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
cfg["whitelist_phrases"] = sorted(after_wl)
|
||||
cfg.setdefault("blacklist", {})
|
||||
cfg["blacklist"]["force_mask_terms"] = sorted(after_bl)
|
||||
config_path.write_text(
|
||||
yaml.dump(cfg, allow_unicode=True, default_flow_style=False, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return True
|
||||
```
|
||||
|
||||
> Note implémentation : fusion autonome (pas d'import de `scripts/`), donc robuste en frozen. Le format JSON (`whitelist_phrases` / `blacklist_force_mask_terms`) reste celui consommé par `scripts/merge_params.py:52-58` → compatibilité email V5 préservée.
|
||||
|
||||
- [ ] **Step 4 : Recâbler `_build_partage` (vrais boutons)**
|
||||
|
||||
Remplacer les deux `self._mockup_button(...)` de `_build_partage` (`tab_config.py:847,855`) par de vrais boutons :
|
||||
|
||||
```python
|
||||
ui_kit.secondary_button(
|
||||
export, p, "⬇ Exporter (.json)", command=self._on_export_config
|
||||
).pack(anchor="w", padx=12, pady=(0, 12))
|
||||
...
|
||||
ui_kit.secondary_button(
|
||||
import_card, p, "⬆ Importer (.json)", command=self._on_import_config
|
||||
).pack(anchor="w", padx=12, pady=(0, 12))
|
||||
```
|
||||
|
||||
Ajouter les handlers (section « callbacks réglages », après `_on_manual_mask_template`) :
|
||||
|
||||
```python
|
||||
def _on_export_config(self) -> None:
|
||||
from gui_v6.config_share import build_export_payload
|
||||
import json
|
||||
|
||||
lists = getattr(self, "_pro_term_lists", {})
|
||||
wl = lists["whitelist"].terms() if "whitelist" in lists else []
|
||||
bl = lists["blacklist"].terms() if "blacklist" in lists else []
|
||||
path = filedialog.asksaveasfilename(
|
||||
title="Exporter la configuration", defaultextension=".json",
|
||||
filetypes=[("Configuration JSON", "*.json")],
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
payload = build_export_payload(wl, bl)
|
||||
Path(path).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
messagebox.showinfo("Export", f"Configuration exportée :\n{path}")
|
||||
except Exception as exc: # pragma: no cover - chemin UI
|
||||
messagebox.showerror("Export", f"Échec de l'export : {exc}")
|
||||
|
||||
def _on_import_config(self) -> None:
|
||||
from gui_v6.config_share import import_config_file
|
||||
|
||||
if self._config_path is None:
|
||||
messagebox.showerror("Import", "Aucune configuration cible résolue.")
|
||||
return
|
||||
path = filedialog.askopenfilename(
|
||||
title="Importer une configuration",
|
||||
filetypes=[("Configuration JSON", "*.json")],
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
changed = import_config_file(path, self._config_path)
|
||||
if changed:
|
||||
messagebox.showinfo("Import", "Configuration fusionnée. Redémarrez pour appliquer.")
|
||||
else:
|
||||
messagebox.showinfo("Import", "Rien de nouveau à fusionner.")
|
||||
except Exception as exc: # pragma: no cover - chemin UI
|
||||
messagebox.showerror("Import", f"Échec de l'import : {exc}")
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Donner à `ConfigTab` le `config_path`**
|
||||
|
||||
Dans `ConfigTab.__init__`, accepter et stocker `config_path` (signature : ajouter `config_path: Path | None = None`, et `self._config_path = config_path`). Dans `gui_v6/app.py:_create_tab`, branche `"cfg"` :
|
||||
|
||||
```python
|
||||
if key == "cfg":
|
||||
return ConfigTab(self._content, palette=p, state=self._config, config_path=self._user_config_path)
|
||||
```
|
||||
|
||||
- [ ] **Step 6 : Lancer → vérifier le succès + non-régression**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_config_share.py tests/unit/test_gui_v6_config_mockup_sections.py tests/unit/test_gui_v6_app_shell.py -q`
|
||||
Expected: PASS (le test `test_config_mockup_sections` reste vert : ne pas changer `CONFIG_MOCKUP_SECTIONS`, seulement le câblage des boutons Partage).
|
||||
|
||||
- [ ] **Step 7 : Commit**
|
||||
|
||||
```bash
|
||||
git add gui_v6/config_share.py gui_v6/tabs/tab_config.py gui_v6/app.py tests/unit/test_gui_v6_config_share.py
|
||||
git commit -m "feat(gui): recâbler import/export de configuration par email (P1-3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5 : P1-1 — Dropzone honnête (cliquable)
|
||||
|
||||
**Problème :** La zone « Choisissez vos fichiers » (`tab_usage.py:103-115`) suggère un glisser-déposer inexistant. Le DnD natif (`tkinterdnd2`) impose la lib native tkdnd à bundler dans le frozen → **différé** (ne pas alourdir le build torch-free du Plan 3). Fix bêta : rendre la zone **cliquable** (ouvre le sélecteur de fichier) + libellé honnête. Les boutons Fichier/Dossier existants restent.
|
||||
|
||||
**Files:**
|
||||
- Modify: `gui_v6/tabs/tab_usage.py` (rendre la dropzone cliquable + libellé)
|
||||
|
||||
- [ ] **Step 1 : Rendre la dropzone cliquable + libellé honnête**
|
||||
|
||||
Dans `_build`, remplacer le label « Choisissez vos fichiers » et lier le clic. Après la création de `dz` (`tab_usage.py:103-106`), lier le clic de la zone et de ses labels à `_pick_file` :
|
||||
|
||||
```python
|
||||
dz.bind("<Button-1>", lambda _e: self._pick_file())
|
||||
ctk.CTkLabel(dz, text="⬆️", font=ui_kit.font(30)).pack(pady=(20, 4))
|
||||
zone_lbl = ctk.CTkLabel(dz, text="Cliquez pour choisir un fichier", text_color=p["text"], font=ui_kit.font(14))
|
||||
zone_lbl.pack()
|
||||
zone_lbl.bind("<Button-1>", lambda _e: self._pick_file())
|
||||
ctk.CTkLabel(dz, text="PDF · Word · Images · Texte", text_color=p["text_muted"], font=ui_kit.font(12)).pack(pady=(2, 10))
|
||||
```
|
||||
|
||||
(la ligne `ctk.CTkLabel(dz, text="⬆️"...)` d'origine est remplacée par le bloc ci-dessus ; les boutons `acts` restent inchangés).
|
||||
|
||||
- [ ] **Step 2 : Smoke import (pas de régression d'import)**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_processing_runner.py -q && .venv/bin/python Pseudonymisation_Gui_V6.py --self-test`
|
||||
Expected: PASS + `GUI V6 self-test OK`.
|
||||
|
||||
- [ ] **Step 3 : Commit**
|
||||
|
||||
```bash
|
||||
git add gui_v6/tabs/tab_usage.py
|
||||
git commit -m "feat(gui): dropzone cliquable + libellé honnête (P1-1, DnD natif différé)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6 (optionnel) : P1-6 — Valider l'inscriptibilité du dossier de sortie
|
||||
|
||||
**Problème :** Un dossier de sortie en lecture seule fait échouer **chaque** document avec un message cryptique (`processing_runner.py:215` `mkdir` puis échec par doc). Tester l'inscriptibilité en amont donne un message clair unique.
|
||||
|
||||
**Files:**
|
||||
- Modify: `gui_v6/processing_runner.py` (`_run_impl` : test d'écriture amont)
|
||||
- Create/Modify: `tests/unit/test_gui_v6_processing_runner.py` (nouveau test)
|
||||
|
||||
- [ ] **Step 1 : Test (échoue)**
|
||||
|
||||
Ajouter à `tests/unit/test_gui_v6_processing_runner.py` :
|
||||
|
||||
```python
|
||||
def test_run_fails_fast_when_output_not_writable(tmp_path, monkeypatch):
|
||||
from gui_v6.processing_runner import ProcessingRunner, OutputNotWritableError
|
||||
src = tmp_path / "in"
|
||||
src.mkdir()
|
||||
(src / "a.txt").write_text("x", encoding="utf-8")
|
||||
out = tmp_path / "ro"
|
||||
out.mkdir()
|
||||
|
||||
def boom(*a, **k):
|
||||
raise PermissionError("read-only")
|
||||
|
||||
monkeypatch.setattr("gui_v6.processing_runner.Path.mkdir", boom)
|
||||
runner = ProcessingRunner(process_fn=lambda d, o: {})
|
||||
import pytest
|
||||
with pytest.raises(OutputNotWritableError):
|
||||
runner.run(src, out)
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer → vérifier l'échec**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_processing_runner.py::test_run_fails_fast_when_output_not_writable -q`
|
||||
Expected: FAIL (`OutputNotWritableError` absent).
|
||||
|
||||
- [ ] **Step 3 : Implémenter le garde-fou**
|
||||
|
||||
Dans `gui_v6/processing_runner.py`, ajouter la classe d'erreur (près des dataclasses) :
|
||||
|
||||
```python
|
||||
class OutputNotWritableError(RuntimeError):
|
||||
"""Le dossier de sortie n'est pas inscriptible (échec amont, message clair)."""
|
||||
```
|
||||
|
||||
Dans `_run_impl`, après le calcul de `out_root` (`:186`) et **avant** la boucle, vérifier une fois :
|
||||
|
||||
```python
|
||||
try:
|
||||
out_root.mkdir(parents=True, exist_ok=True)
|
||||
probe = out_root / ".anon_write_test"
|
||||
probe.write_text("", encoding="utf-8")
|
||||
probe.unlink()
|
||||
except Exception as exc:
|
||||
raise OutputNotWritableError(
|
||||
f"Dossier de sortie non inscriptible : {out_root} ({exc})"
|
||||
) from exc
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer → vérifier le succès + non-régression runner**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_processing_runner.py -q`
|
||||
Expected: PASS (nouveau + existants).
|
||||
|
||||
> Note UI : `UsageTab._handle_event`/`work()` capture déjà `Exception` → le message d'`OutputNotWritableError` s'affiche dans le journal. Vérifier qu'il remonte lisible (pas de doublon par document).
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add gui_v6/processing_runner.py tests/unit/test_gui_v6_processing_runner.py
|
||||
git commit -m "feat(gui): échec amont clair si dossier de sortie non inscriptible (P1-6)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7 (optionnel) : P1-11 — Provenance audit `docTR` → `OnnxTR`
|
||||
|
||||
**Problème :** L'audit JSONL trace `original="docTR"` (`anonymizer_core_refactored_onnx.py:5356`) alors que l'OCR est OnnxTR depuis la migration. Trace périmée visible dans le livrable client.
|
||||
|
||||
**Files:**
|
||||
- Modify: `anonymizer_core_refactored_onnx.py:5356`
|
||||
- Test: gate `synthetic_regression` (pas de nouveau test dédié — provenance cosmétique).
|
||||
|
||||
- [ ] **Step 1 : Localiser et corriger**
|
||||
|
||||
Run: `.venv/bin/python - <<'PY'\nimport re,io\np="anonymizer_core_refactored_onnx.py"\ns=open(p,encoding="utf-8").read()\nprint([l for l in s.splitlines() if 'docTR' in l and 'original' in l][:3])\nPY`
|
||||
Remplacer la valeur de provenance `"docTR"` par `"OnnxTR"` à la ligne d'audit identifiée (uniquement la chaîne de provenance OCR, ne pas toucher aux commentaires/docstrings cosmétiques P2-3).
|
||||
|
||||
- [ ] **Step 2 : Non-régression**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/unit -q -k "synthetic_regression or audit"`
|
||||
Expected: PASS (sortie d'anonymisation inchangée ; seule la provenance change).
|
||||
|
||||
- [ ] **Step 3 : Commit**
|
||||
|
||||
```bash
|
||||
git add anonymizer_core_refactored_onnx.py
|
||||
git commit -m "fix(core): corriger la provenance OCR de l'audit (docTR → OnnxTR, P1-11)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Clôture du Plan 1c
|
||||
|
||||
- [ ] **Suite complète + gate**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/unit -q` (attendu : 436 + nouveaux tests, 0 régression) puis le gate `synthetic_regression` du dépôt.
|
||||
|
||||
- [ ] **Self-test GUI**
|
||||
|
||||
Run: `.venv/bin/python Pseudonymisation_Gui_V6.py --self-test`
|
||||
Expected: `GUI V6 self-test OK`.
|
||||
|
||||
- [ ] **Revue + push**
|
||||
|
||||
Revue Qwen **courte** recommandée (1c = honnêteté UI, hors cœur sécurité masquage — pas de revue bloquante comme 1b, mais signaler la décision « confirmation NER » et le modèle export profil-actif). Puis push `gitea/feature/q1-quarantine-mvp`. Mettre à jour la mémoire projet `gui_v6_beta_prod_chantier.md`.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (auteur du plan)
|
||||
|
||||
**Couverture spec (chantier D) :** P1-2 = déjà livré (Plan 1b, hors périmètre ici) ✓ ; P1-1 = Task 5 (cliquable, DnD différé — **déviation assumée** vs spec « tkinterdnd2 », à valider Dom) ; P1-3 = Task 4 ; P1-4 = Task 1 ; P1-5 = Task 3 ; confirmation NER = Task 2 (décision Dom 2026-06-29) ; P1-6 = Task 6 (optionnel) ; P1-11 = Task 7 (optionnel). P2-1/P2-2 non traités (best-effort, non bloquants) — **signalé**.
|
||||
|
||||
**Placeholders :** aucun « TODO / à compléter » ; chaque step porte le code réel.
|
||||
|
||||
**Cohérence des types/symboles :** `resolve_user_config_path` (Task 1) réutilisé en Task 4 (Step 5) ; `failure_hint`/`open_in_file_manager` (Task 3) cohérents avec leur test ; `build_export_payload`/`import_config_file` (Task 4) ⇄ schéma `merge_params` (`whitelist_phrases` / `blacklist_force_mask_terms`) vérifié contre `scripts/merge_params.py:52-58`. `self._tog_ner.var` confirmé exposé par `_mini_toggle` (`tab_config.py:1107`).
|
||||
|
||||
**Hypothèses à confirmer en revue :**
|
||||
1. **Task 4** exporte les listes du **profil chargé dans l'éditeur** (`_pro_term_lists`). Si l'attendu est « toutes les listes globales » ou « le profil par défaut », ajuster la source des listes.
|
||||
2. **Task 5** dévie de la spec (DnD natif → cliquable). Justification : éviter la lib native tkdnd dans le build torch-free. À confirmer.
|
||||
|
||||
*(Hypothèse « scripts importable » levée : la fusion d'import est désormais autonome, frozen-safe — vérifié `scripts/__init__.py` absent et `scripts/` non bundlé.)*
|
||||
@@ -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,12 +189,13 @@ 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,
|
||||
)
|
||||
if key == "cfg":
|
||||
return ConfigTab(self._content, palette=p, state=self._config)
|
||||
return ConfigTab(self._content, palette=p, state=self._config, config_path=self._user_config_path)
|
||||
return AboutTab(
|
||||
self._content,
|
||||
palette=p,
|
||||
|
||||
62
gui_v6/config_paths.py
Normal file
62
gui_v6/config_paths.py
Normal 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
|
||||
72
gui_v6/config_share.py
Normal file
72
gui_v6/config_share.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Échange de configuration par fichier JSON (workflow email V5, P1-3).
|
||||
|
||||
- ``build_export_payload`` : produit le dict V5 (format consommé par
|
||||
``scripts/merge_params.merge_params``) à partir des listes du profil courant ;
|
||||
- ``import_config_file`` : fusionne un JSON reçu dans le ``dictionnaires.yml``
|
||||
utilisateur, sans écraser l'existant.
|
||||
|
||||
Aucune dépendance à un widget : testable en pur Python.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
EXPORT_VERSION = "1"
|
||||
|
||||
|
||||
def build_export_payload(
|
||||
whitelist: Iterable[str], blacklist: Iterable[str], version: str = EXPORT_VERSION
|
||||
) -> dict:
|
||||
"""Construit la charge utile d'export au format consommé par merge_params."""
|
||||
return {
|
||||
"version": version,
|
||||
"date_export": datetime.now(timezone.utc).isoformat(),
|
||||
"whitelist_phrases": [str(t) for t in whitelist],
|
||||
"blacklist_force_mask_terms": [str(t) for t in blacklist],
|
||||
}
|
||||
|
||||
|
||||
def _yaml_lists(config_path: Path) -> tuple[set, set]:
|
||||
import yaml
|
||||
|
||||
cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
wl = set(cfg.get("whitelist_phrases", []) or [])
|
||||
bl = set((cfg.get("blacklist", {}) or {}).get("force_mask_terms", []) or [])
|
||||
return wl, bl
|
||||
|
||||
|
||||
def import_config_file(json_path, config_path) -> bool:
|
||||
"""Fusionne ``json_path`` dans ``config_path`` (YAML). Retourne True si la
|
||||
config a changé, False si rien de nouveau.
|
||||
|
||||
Fusion autonome (union des listes, jamais d'écrasement) — volontairement
|
||||
SANS dépendance à ``scripts/merge_params`` (non bundlé en frozen). Même
|
||||
sémantique : ``whitelist_phrases`` et ``blacklist.force_mask_terms``.
|
||||
"""
|
||||
import json
|
||||
import yaml
|
||||
|
||||
json_path = Path(json_path)
|
||||
config_path = Path(config_path)
|
||||
before_wl, before_bl = _yaml_lists(config_path)
|
||||
|
||||
data = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
incoming_wl = {str(t).strip() for t in data.get("whitelist_phrases", []) if str(t).strip()}
|
||||
incoming_bl = {str(t).strip() for t in data.get("blacklist_force_mask_terms", []) if str(t).strip()}
|
||||
|
||||
after_wl = before_wl | incoming_wl
|
||||
after_bl = before_bl | incoming_bl
|
||||
if after_wl == before_wl and after_bl == before_bl:
|
||||
return False
|
||||
|
||||
cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
cfg["whitelist_phrases"] = sorted(after_wl)
|
||||
cfg.setdefault("blacklist", {})
|
||||
cfg["blacklist"]["force_mask_terms"] = sorted(after_bl)
|
||||
config_path.write_text(
|
||||
yaml.dump(cfg, allow_unicode=True, default_flow_style=False, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return True
|
||||
25
gui_v6/fsutil.py
Normal file
25
gui_v6/fsutil.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Ouverture du gestionnaire de fichiers sur un dossier (cross-plateforme).
|
||||
|
||||
Best-effort : ne lève jamais (un échec d'ouverture ne doit pas casser l'UI).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def open_in_file_manager(path) -> None:
|
||||
"""Ouvre ``path`` dans l'explorateur de fichiers du système."""
|
||||
target = str(Path(path))
|
||||
try:
|
||||
if sys.platform.startswith("win"):
|
||||
import os
|
||||
|
||||
os.startfile(target) # type: ignore[attr-defined] # noqa: S606
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.Popen(["open", target])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", target])
|
||||
except Exception:
|
||||
pass
|
||||
@@ -99,6 +99,10 @@ def discover_documents(input_path, extensions: Optional[Sequence[str]] = None) -
|
||||
return []
|
||||
|
||||
|
||||
class OutputNotWritableError(RuntimeError):
|
||||
"""Le dossier de sortie n'est pas inscriptible (échec amont, message clair)."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class DocResult:
|
||||
"""Détail anonymisé d'un document traité (pour la télémétrie d'usage).
|
||||
@@ -195,6 +199,19 @@ class ProcessingRunner:
|
||||
log("Aucun document supporté détecté.")
|
||||
return summary
|
||||
|
||||
# Sonde amont : on vérifie une seule fois que le dossier de sortie est
|
||||
# inscriptible AVANT la boucle, pour un échec clair et unique (P1-6)
|
||||
# plutôt qu'une erreur cryptique répétée à chaque document.
|
||||
try:
|
||||
out_root.mkdir(parents=True, exist_ok=True)
|
||||
probe = out_root / ".anon_write_test"
|
||||
probe.write_text("", encoding="utf-8")
|
||||
probe.unlink()
|
||||
except Exception as exc:
|
||||
raise OutputNotWritableError(
|
||||
f"Dossier de sortie non inscriptible : {out_root} ({exc})"
|
||||
) from exc
|
||||
|
||||
for index, doc in enumerate(docs, start=1):
|
||||
if stop_event is not None and stop_event.is_set():
|
||||
summary.stopped = True
|
||||
|
||||
@@ -20,6 +20,31 @@ from gui_v6 import ui_kit
|
||||
from gui_v6.config_state import ConfigState, default_profile_key, list_profile_keys
|
||||
from manual_masking import ensure_mask_templates_dir, list_mask_templates, mask_template_label
|
||||
|
||||
NER_DISABLE_WARNING = (
|
||||
"Vous allez désactiver le moteur d'intelligence artificielle "
|
||||
"(CamemBERT-bio).\n\n"
|
||||
"Sans lui, la détection des NOMS de personnes repose uniquement sur des "
|
||||
"règles (expressions régulières) : des noms peuvent rester EN CLAIR dans "
|
||||
"les documents.\n\n"
|
||||
"Pour un usage médical, garder ce moteur activé est fortement recommandé.\n\n"
|
||||
"Confirmer la désactivation ?"
|
||||
)
|
||||
|
||||
|
||||
def confirm_ner_disable(asker) -> bool:
|
||||
"""Décision de désactivation du NER.
|
||||
|
||||
``asker`` est une fonction ``() -> bool`` (ex. ``messagebox.askyesno``),
|
||||
injectée pour rester testable sans display. Retourne True si l'utilisateur
|
||||
CONFIRME la désactivation (regex-only). Toute erreur de l'asker est traitée
|
||||
comme un refus (sens sûr : le NER reste actif).
|
||||
"""
|
||||
try:
|
||||
return bool(asker())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
_SUBTABS = [
|
||||
("reg", "⚙️ Réglages"),
|
||||
("pro", "👤 Profils"),
|
||||
@@ -145,12 +170,15 @@ _HELP_PROFIL_MOTS = (
|
||||
"Pour de longues listes, utilisez le tableau des termes afin de rechercher et vérifier plus facilement."
|
||||
)
|
||||
_HELP_EXPORT_CONFIG = (
|
||||
"Exporte uniquement les réglages de l'application : profils, listes locales, règles et style de masque.\n\n"
|
||||
"Exporte vos deux listes locales — termes à toujours conserver et termes à toujours "
|
||||
"masquer — dans un fichier .json à envoyer à un autre poste ou à l'administrateur.\n\n"
|
||||
"Les documents patients, résultats d'anonymisation et audits ne sont pas exportés."
|
||||
)
|
||||
_HELP_IMPORT_CONFIG = (
|
||||
"Importe des réglages reçus d'un administrateur ou d'un autre poste.\n\n"
|
||||
"L'import ne lit pas de documents patients. Vérifiez toujours le profil actif après import."
|
||||
"Importe les deux listes locales (termes à conserver, termes à masquer) reçues d'un "
|
||||
"autre poste ou de l'administrateur. La fusion ajoute ces termes à votre configuration "
|
||||
"sans rien écraser.\n\n"
|
||||
"L'import ne lit pas de documents patients. Redémarrez l'application pour appliquer."
|
||||
)
|
||||
|
||||
CONFIG_MOCKUP_SECTIONS = {
|
||||
@@ -198,10 +226,11 @@ def _app_base_dir() -> Path:
|
||||
|
||||
|
||||
class ConfigTab(ctk.CTkFrame):
|
||||
def __init__(self, master, state: ConfigState | None = None, palette: dict | None = None, **kwargs):
|
||||
def __init__(self, master, state: ConfigState | None = None, palette: dict | None = None, config_path: Path | None = None, **kwargs):
|
||||
self._p = palette or theme_mod.get_palette(theme_mod.DEFAULT_THEME)
|
||||
super().__init__(master, fg_color=self._p["bg"], **kwargs)
|
||||
self._state = state if state is not None else ConfigState()
|
||||
self._config_path = config_path
|
||||
self._sub = "reg"
|
||||
self._sub_buttons: dict[str, ctk.CTkButton] = {}
|
||||
self._panels: dict[str, ctk.CTkFrame] = {}
|
||||
@@ -843,16 +872,20 @@ class ConfigTab(ctk.CTkFrame):
|
||||
help_text=_HELP_EXPORT_CONFIG, help_title="Exporter la configuration",
|
||||
)
|
||||
export.pack(fill="both", expand=True)
|
||||
self._note(export, "Listes locales, règles admin, style de masquage et template actif.")
|
||||
self._mockup_button(export, "⬇ Exporter (.json)").pack(anchor="w", padx=12, pady=(0, 12))
|
||||
self._note(export, "Vos listes locales : termes à toujours conserver et termes à toujours masquer.")
|
||||
ui_kit.secondary_button(
|
||||
export, p, "⬇ Exporter (.json)", command=self._on_export_config
|
||||
).pack(anchor="w", padx=12, pady=(0, 12))
|
||||
|
||||
import_card = ui_kit.Card(
|
||||
cols[1], p, title="📥 Importer une configuration",
|
||||
help_text=_HELP_IMPORT_CONFIG, help_title="Importer une configuration",
|
||||
)
|
||||
import_card.pack(fill="both", expand=True)
|
||||
self._note(import_card, "Fusionne la configuration reçue avec vos réglages locaux.")
|
||||
self._mockup_button(import_card, "⬆ Importer (.json)").pack(anchor="w", padx=12, pady=(0, 12))
|
||||
self._note(import_card, "Fusionne les listes reçues (à conserver / à masquer) avec vos listes locales.")
|
||||
ui_kit.secondary_button(
|
||||
import_card, p, "⬆ Importer (.json)", command=self._on_import_config
|
||||
).pack(anchor="w", padx=12, pady=(0, 12))
|
||||
|
||||
# -- helpers aide / maquette -----------------------------------------
|
||||
|
||||
@@ -891,7 +924,19 @@ class ConfigTab(ctk.CTkFrame):
|
||||
setattr(self._state, field_name, bool(toggle.get()))
|
||||
|
||||
def _on_ner(self) -> None:
|
||||
self._state.use_local_ner = self._tog_ner.get()
|
||||
new_value = self._tog_ner.get()
|
||||
if not new_value:
|
||||
confirmed = confirm_ner_disable(
|
||||
lambda: messagebox.askyesno(
|
||||
"Moteur de détection", NER_DISABLE_WARNING, icon="warning"
|
||||
)
|
||||
)
|
||||
if not confirmed:
|
||||
# Refus : rétablir l'affichage du switch et garder le NER actif.
|
||||
self._tog_ner.var.set(True)
|
||||
self._state.use_local_ner = True
|
||||
return
|
||||
self._state.use_local_ner = new_value
|
||||
|
||||
def _on_eds(self) -> None:
|
||||
self._state.enable_eds = self._tog_eds.get()
|
||||
@@ -905,6 +950,47 @@ class ConfigTab(ctk.CTkFrame):
|
||||
def _on_manual_mask_template(self, label: str) -> None:
|
||||
self._state.manual_mask_template = self._manual_mask_templates.get(label)
|
||||
|
||||
def _on_export_config(self) -> None:
|
||||
from gui_v6.config_share import build_export_payload
|
||||
import json
|
||||
|
||||
lists = getattr(self, "_pro_term_lists", {})
|
||||
wl = lists["whitelist"].terms() if "whitelist" in lists else []
|
||||
bl = lists["blacklist"].terms() if "blacklist" in lists else []
|
||||
path = filedialog.asksaveasfilename(
|
||||
title="Exporter la configuration", defaultextension=".json",
|
||||
filetypes=[("Configuration JSON", "*.json")],
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
payload = build_export_payload(wl, bl)
|
||||
Path(path).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
messagebox.showinfo("Export", f"Configuration exportée :\n{path}")
|
||||
except Exception as exc: # pragma: no cover - chemin UI
|
||||
messagebox.showerror("Export", f"Échec de l'export : {exc}")
|
||||
|
||||
def _on_import_config(self) -> None:
|
||||
from gui_v6.config_share import import_config_file
|
||||
|
||||
if self._config_path is None:
|
||||
messagebox.showerror("Import", "Aucune configuration cible résolue.")
|
||||
return
|
||||
path = filedialog.askopenfilename(
|
||||
title="Importer une configuration",
|
||||
filetypes=[("Configuration JSON", "*.json")],
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
changed = import_config_file(path, self._config_path)
|
||||
if changed:
|
||||
messagebox.showinfo("Import", "Configuration fusionnée. Redémarrez pour appliquer.")
|
||||
else:
|
||||
messagebox.showinfo("Import", "Rien de nouveau à fusionner.")
|
||||
except Exception as exc: # pragma: no cover - chemin UI
|
||||
messagebox.showerror("Import", f"Échec de l'import : {exc}")
|
||||
|
||||
def _pick_output(self) -> None:
|
||||
path = filedialog.askdirectory(title="Dossier de sortie")
|
||||
if path:
|
||||
|
||||
@@ -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.fsutil import open_in_file_manager
|
||||
from gui_v6.processing_runner import ProcessingRunner, default_output_dir
|
||||
|
||||
_STEPS = ["📖 Extraction", "🧠 Détection", "🔒 Masquage", "📄 PDF final"]
|
||||
@@ -33,6 +34,29 @@ _HELP_USAGE = (
|
||||
)
|
||||
|
||||
|
||||
def failure_hint(summary, output_dir) -> str | None:
|
||||
"""Message localisant les documents livrés, ou None si run nominal.
|
||||
|
||||
Honnête : les documents en échec / quarantaine ne sont PAS anonymisés et
|
||||
ne sont donc pas écrits. Si AUCUN document n'a abouti, on ne prétend pas
|
||||
qu'un dossier de sortie contient des documents anonymisés.
|
||||
"""
|
||||
if summary is None or output_dir is None:
|
||||
return None
|
||||
if summary.failed == 0 and not getattr(summary, "stopped", False):
|
||||
return None
|
||||
if summary.succeeded == 0:
|
||||
return (
|
||||
"Aucun document n'a été anonymisé. Les documents en échec ou en "
|
||||
"quarantaine ne sont PAS anonymisés et n'ont pas été écrits."
|
||||
)
|
||||
return (
|
||||
f"Documents anonymisés écrits dans : {output_dir}\n"
|
||||
"Les documents en échec ou en quarantaine ne sont PAS anonymisés et "
|
||||
"n'ont pas été écrits."
|
||||
)
|
||||
|
||||
|
||||
class UsageTab(ctk.CTkFrame):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -59,6 +83,7 @@ class UsageTab(ctk.CTkFrame):
|
||||
|
||||
self._input_path: Path | None = None
|
||||
self._output_dir: Path | None = None
|
||||
self._last_output_dir: Path | None = None
|
||||
self._stop_event: threading.Event | None = None
|
||||
self._is_running = False
|
||||
self._events: "queue.Queue[tuple]" = queue.Queue()
|
||||
@@ -104,9 +129,19 @@ class UsageTab(ctk.CTkFrame):
|
||||
docs, fg_color=p["divider"], border_color=p["card_border"], border_width=2, corner_radius=8
|
||||
)
|
||||
dz.pack(fill="x", padx=16, pady=(0, 8))
|
||||
ctk.CTkLabel(dz, text="⬆️", font=ui_kit.font(30)).pack(pady=(20, 4))
|
||||
ctk.CTkLabel(dz, text="Choisissez vos fichiers", text_color=p["text"], font=ui_kit.font(14)).pack()
|
||||
ctk.CTkLabel(dz, text="PDF · Word · Images · Texte", text_color=p["text_muted"], font=ui_kit.font(12)).pack(pady=(2, 10))
|
||||
# Zone réellement cliquable (ouvre le sélecteur de fichier). Le DnD natif
|
||||
# est différé ; on ne laisse pas une métaphore « déposer » trompeuse.
|
||||
dz.configure(cursor="hand2")
|
||||
dz.bind("<Button-1>", lambda _e: self._pick_file())
|
||||
for _txt, _color, _size, _pady in (
|
||||
("⬆️", p["text"], 30, (20, 4)),
|
||||
("Cliquez pour choisir un fichier", p["text"], 14, 0),
|
||||
("PDF · Word · Images · Texte", p["text_muted"], 12, (2, 10)),
|
||||
):
|
||||
_lbl = ctk.CTkLabel(dz, text=_txt, text_color=_color, font=ui_kit.font(_size))
|
||||
_lbl.configure(cursor="hand2")
|
||||
_lbl.bind("<Button-1>", lambda _e: self._pick_file())
|
||||
_lbl.pack(pady=_pady)
|
||||
acts = ctk.CTkFrame(dz, fg_color="transparent")
|
||||
acts.pack(pady=(0, 20))
|
||||
ui_kit.secondary_button(acts, p, "📄 Fichier", command=self._pick_file).pack(side="left", padx=4)
|
||||
@@ -158,6 +193,7 @@ class UsageTab(ctk.CTkFrame):
|
||||
self._stats_row = ctk.CTkFrame(self._rsec, fg_color="transparent")
|
||||
self._stats_row.pack(fill="x", padx=16, pady=(0, 14))
|
||||
self._result_built = False
|
||||
self._hint_row = None
|
||||
|
||||
# -- thème ------------------------------------------------------------
|
||||
|
||||
@@ -211,6 +247,7 @@ class UsageTab(ctk.CTkFrame):
|
||||
return
|
||||
self._is_running = True
|
||||
run_runner, run_output_dir = self._build_run_runner()
|
||||
self._last_output_dir = run_output_dir or default_output_dir(self._input_path)
|
||||
self._stop_event = threading.Event()
|
||||
self._run_btn.configure(state="disabled")
|
||||
self._psec.pack(fill="x", padx=14, pady=7)
|
||||
@@ -281,6 +318,7 @@ class UsageTab(ctk.CTkFrame):
|
||||
self._progress.set(1.0)
|
||||
self._set_status(f"Terminé : {summary.succeeded} OK, {summary.failed} échec(s) sur {summary.total}.")
|
||||
self._show_results(summary)
|
||||
self._show_failure_hint(summary)
|
||||
self._send_usage_telemetry(summary)
|
||||
|
||||
def _send_usage_telemetry(self, summary) -> None:
|
||||
@@ -311,6 +349,28 @@ class UsageTab(ctk.CTkFrame):
|
||||
ui_kit.StatCard(self._stats_row, p, value, label, value_color=color).pack(side="left", expand=True, fill="x", padx=4)
|
||||
self._rsec.pack(fill="x", padx=14, pady=(7, 14))
|
||||
|
||||
def _show_failure_hint(self, summary) -> None:
|
||||
# Nettoyage inconditionnel : éviter l'empilement et les hints périmés.
|
||||
if getattr(self, "_hint_row", None) is not None:
|
||||
self._hint_row.destroy()
|
||||
self._hint_row = None
|
||||
hint = failure_hint(summary, getattr(self, "_last_output_dir", None))
|
||||
if hint is None:
|
||||
return
|
||||
p = self._p
|
||||
self._hint_row = ctk.CTkFrame(self._rsec, fg_color="transparent")
|
||||
self._hint_row.pack(fill="x", padx=16, pady=(0, 12))
|
||||
ctk.CTkLabel(
|
||||
self._hint_row, text=hint, text_color=p["text_dim"], font=ui_kit.font(11),
|
||||
anchor="w", justify="left",
|
||||
).pack(side="left", fill="x", expand=True)
|
||||
# Bouton « ouvrir » uniquement s'il y a réellement des documents livrés.
|
||||
if summary is not None and summary.succeeded > 0:
|
||||
ui_kit.secondary_button(
|
||||
self._hint_row, p, "📂 Ouvrir le dossier",
|
||||
command=lambda: open_in_file_manager(self._last_output_dir),
|
||||
).pack(side="right")
|
||||
|
||||
# -- helpers widgets --------------------------------------------------
|
||||
|
||||
def _set_status(self, text: str) -> None:
|
||||
|
||||
82
tests/unit/test_gui_v6_config_paths.py
Normal file
82
tests/unit/test_gui_v6_config_paths.py
Normal 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()
|
||||
95
tests/unit/test_gui_v6_config_share.py
Normal file
95
tests/unit/test_gui_v6_config_share.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Export / import de configuration (P1-3) — format compatible merge_params.
|
||||
|
||||
Pur : sérialisation/désérialisation et fusion, sans display ni filedialog.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from gui_v6.config_share import build_export_payload, import_config_file
|
||||
|
||||
|
||||
def test_export_payload_has_v5_schema():
|
||||
payload = build_export_payload(
|
||||
whitelist=["Dr Métier", "Service ORL"],
|
||||
blacklist=["DUPONT"],
|
||||
version="2026.06.29",
|
||||
)
|
||||
assert payload["version"] == "2026.06.29"
|
||||
assert "date_export" in payload
|
||||
assert payload["whitelist_phrases"] == ["Dr Métier", "Service ORL"]
|
||||
assert payload["blacklist_force_mask_terms"] == ["DUPONT"]
|
||||
|
||||
|
||||
def test_export_payload_is_json_serializable():
|
||||
payload = build_export_payload(whitelist=["A"], blacklist=["B"], version="1")
|
||||
json.dumps(payload) # ne doit pas lever
|
||||
|
||||
|
||||
def test_import_merges_into_user_config(tmp_path, monkeypatch):
|
||||
cfg = tmp_path / "dictionnaires.yml"
|
||||
cfg.write_text("whitelist_phrases: [Existant]\n", encoding="utf-8")
|
||||
incoming = tmp_path / "recu.json"
|
||||
incoming.write_text(
|
||||
json.dumps({
|
||||
"version": "1", "date_export": "2026-06-29",
|
||||
"whitelist_phrases": ["Nouveau"],
|
||||
"blacklist_force_mask_terms": ["MASQUERMOI"],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
added = import_config_file(incoming, cfg)
|
||||
assert added is True
|
||||
import yaml
|
||||
merged = yaml.safe_load(cfg.read_text(encoding="utf-8"))
|
||||
assert "Existant" in merged["whitelist_phrases"]
|
||||
assert "Nouveau" in merged["whitelist_phrases"]
|
||||
assert "MASQUERMOI" in merged["blacklist"]["force_mask_terms"]
|
||||
|
||||
|
||||
def test_import_returns_false_when_nothing_new(tmp_path):
|
||||
cfg = tmp_path / "dictionnaires.yml"
|
||||
cfg.write_text("whitelist_phrases: [Deja]\n", encoding="utf-8")
|
||||
incoming = tmp_path / "recu.json"
|
||||
incoming.write_text(
|
||||
json.dumps({"whitelist_phrases": ["Deja"], "blacklist_force_mask_terms": []}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert import_config_file(incoming, cfg) is False
|
||||
|
||||
|
||||
def test_import_preserves_unmanaged_yaml_keys(tmp_path):
|
||||
# Une config riche : l'import ne doit toucher QUE whitelist/blacklist,
|
||||
# et préserver toutes les autres sections (anti-perte de données).
|
||||
cfg = tmp_path / "dictionnaires.yml"
|
||||
cfg.write_text(
|
||||
"version: 3\n"
|
||||
"whitelist_phrases: [Existant]\n"
|
||||
"blacklist:\n"
|
||||
" force_mask_terms: [DEJA]\n"
|
||||
" autre_sous_cle: [GARDER]\n"
|
||||
"regex_overrides:\n"
|
||||
" - rule_a\n"
|
||||
"flags:\n"
|
||||
" strict: true\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
incoming = tmp_path / "recu.json"
|
||||
incoming.write_text(
|
||||
json.dumps({"whitelist_phrases": ["Nouveau"], "blacklist_force_mask_terms": ["MASQUERMOI"]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert import_config_file(incoming, cfg) is True
|
||||
import yaml
|
||||
merged = yaml.safe_load(cfg.read_text(encoding="utf-8"))
|
||||
# Clés non gérées intactes
|
||||
assert merged["version"] == 3
|
||||
assert merged["regex_overrides"] == ["rule_a"]
|
||||
assert merged["flags"] == {"strict": True}
|
||||
assert merged["blacklist"]["autre_sous_cle"] == ["GARDER"]
|
||||
# Listes gérées fusionnées
|
||||
assert "Existant" in merged["whitelist_phrases"]
|
||||
assert "Nouveau" in merged["whitelist_phrases"]
|
||||
assert "DEJA" in merged["blacklist"]["force_mask_terms"]
|
||||
assert "MASQUERMOI" in merged["blacklist"]["force_mask_terms"]
|
||||
31
tests/unit/test_gui_v6_ner_confirm.py
Normal file
31
tests/unit/test_gui_v6_ner_confirm.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Confirmation avant de désactiver le NER (regex-only) — outil médical.
|
||||
|
||||
Pur : la décision est isolée dans ``confirm_ner_disable(asker)`` ; ``asker`` est
|
||||
injecté (pas de messagebox réel, pas de display).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from gui_v6.tabs.tab_config import NER_DISABLE_WARNING, confirm_ner_disable
|
||||
|
||||
|
||||
def test_confirm_true_when_user_accepts():
|
||||
assert confirm_ner_disable(lambda: True) is True
|
||||
|
||||
|
||||
def test_confirm_false_when_user_declines():
|
||||
assert confirm_ner_disable(lambda: False) is False
|
||||
|
||||
|
||||
def test_confirm_false_when_asker_raises():
|
||||
def boom():
|
||||
raise RuntimeError("Tk indisponible")
|
||||
# Sens sûr : une erreur de dialogue ne désactive jamais le NER.
|
||||
assert confirm_ner_disable(boom) is False
|
||||
|
||||
|
||||
def test_warning_text_is_explicit_for_medical_use():
|
||||
txt = NER_DISABLE_WARNING.lower()
|
||||
# L'avertissement DOIT nommer la dégradation : règles/regex + risque noms.
|
||||
assert "règles" in txt or "regex" in txt
|
||||
assert "nom" in txt
|
||||
assert "recommand" in txt # « fortement recommandé »
|
||||
@@ -197,6 +197,23 @@ def test_progress_callbacks(tmp_path):
|
||||
assert (2, 2) in events # progression finale atteinte
|
||||
|
||||
|
||||
def test_run_fails_fast_when_output_not_writable(tmp_path, monkeypatch):
|
||||
from gui_v6.processing_runner import ProcessingRunner, OutputNotWritableError
|
||||
src = tmp_path / "in"
|
||||
src.mkdir()
|
||||
(src / "a.txt").write_text("x", encoding="utf-8")
|
||||
out = tmp_path / "ro"
|
||||
out.mkdir()
|
||||
|
||||
def boom(*a, **k):
|
||||
raise PermissionError("read-only")
|
||||
|
||||
monkeypatch.setattr("gui_v6.processing_runner.Path.mkdir", boom)
|
||||
runner = ProcessingRunner(process_fn=lambda d, o: {})
|
||||
with pytest.raises(OutputNotWritableError):
|
||||
runner.run(src, out)
|
||||
|
||||
|
||||
def test_no_double_run(tmp_path):
|
||||
_touch(tmp_path / "a.pdf")
|
||||
started = threading.Event()
|
||||
|
||||
118
tests/unit/test_gui_v6_result_hint.py
Normal file
118
tests/unit/test_gui_v6_result_hint.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Message d'aide localisant les documents non livrés (P1-5) + ouverture dossier.
|
||||
|
||||
Pur : pas de display. ``failure_hint`` formate un texte ; ``open_in_file_manager``
|
||||
dispatch vers la bonne commande OS (monkeypatchée).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import gui_v6.fsutil as fsutil
|
||||
from gui_v6.processing_runner import RunSummary
|
||||
from gui_v6.tabs.tab_usage import failure_hint
|
||||
|
||||
|
||||
def test_no_hint_when_all_ok():
|
||||
s = RunSummary(total=3, succeeded=3, failed=0)
|
||||
assert failure_hint(s, Path("/out")) is None
|
||||
|
||||
|
||||
def test_hint_when_failures_mentions_output_dir():
|
||||
s = RunSummary(total=3, succeeded=2, failed=1)
|
||||
hint = failure_hint(s, Path("/out/anonymise"))
|
||||
assert hint is not None
|
||||
assert "/out/anonymise" in hint
|
||||
# Honnêteté : préciser que les échecs ne sont PAS anonymisés.
|
||||
assert "pas" in hint.lower()
|
||||
|
||||
|
||||
def test_hint_when_stopped():
|
||||
s = RunSummary(total=3, succeeded=1, failed=0, stopped=True)
|
||||
assert failure_hint(s, Path("/out")) is not None
|
||||
|
||||
|
||||
def test_no_hint_without_output_dir():
|
||||
s = RunSummary(total=1, succeeded=0, failed=1)
|
||||
assert failure_hint(s, None) is None
|
||||
|
||||
|
||||
def test_open_in_file_manager_dispatches(monkeypatch):
|
||||
calls = {}
|
||||
monkeypatch.setattr(fsutil.sys, "platform", "linux")
|
||||
monkeypatch.setattr(fsutil.subprocess, "Popen", lambda args, **k: calls.setdefault("args", args))
|
||||
fsutil.open_in_file_manager(Path("/out"))
|
||||
assert calls["args"][0] == "xdg-open"
|
||||
assert calls["args"][1] == "/out"
|
||||
|
||||
|
||||
def test_no_claim_written_when_zero_succeeded():
|
||||
"""0 succès : ne pas prétendre qu'un dossier contient des documents écrits."""
|
||||
s = RunSummary(total=2, succeeded=0, failed=2)
|
||||
hint = failure_hint(s, Path("/out"))
|
||||
assert hint is not None
|
||||
assert "écrits dans" not in hint
|
||||
assert "Aucun document" in hint
|
||||
|
||||
|
||||
def test_hint_with_path_when_some_succeeded():
|
||||
"""≥1 succès : localiser le dossier de sortie effectif."""
|
||||
s = RunSummary(total=3, succeeded=2, failed=1)
|
||||
hint = failure_hint(s, Path("/out"))
|
||||
assert hint is not None
|
||||
assert "/out" in hint
|
||||
assert "écrits dans" in hint
|
||||
|
||||
|
||||
# -- garde anti-régression du bug Critical (empilement de widgets) -----------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def usage_tab():
|
||||
"""``UsageTab`` headless (Xvfb) — skip propre si pas de display."""
|
||||
pytest.importorskip("customtkinter")
|
||||
try:
|
||||
import customtkinter as ctk
|
||||
|
||||
from gui_v6.tabs.tab_usage import UsageTab
|
||||
|
||||
root = ctk.CTk()
|
||||
root.withdraw()
|
||||
tab = UsageTab(root)
|
||||
except Exception as exc: # pas de display Tk
|
||||
pytest.skip(f"display Tk indisponible: {exc}")
|
||||
try:
|
||||
yield tab
|
||||
finally:
|
||||
try:
|
||||
root.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def test_show_failure_hint_does_not_accumulate(usage_tab):
|
||||
"""Bug Critical : deux runs en échec ne doivent pas empiler de hints sous
|
||||
``_rsec`` (handle ``_hint_row`` détruit inconditionnellement)."""
|
||||
usage_tab._last_output_dir = Path("/out")
|
||||
summary = RunSummary(total=3, succeeded=2, failed=1)
|
||||
|
||||
usage_tab._show_failure_hint(summary)
|
||||
count_after_first = len(usage_tab._rsec.winfo_children())
|
||||
|
||||
usage_tab._show_failure_hint(summary)
|
||||
count_after_second = len(usage_tab._rsec.winfo_children())
|
||||
|
||||
assert count_after_second == count_after_first
|
||||
|
||||
|
||||
def test_show_failure_hint_clears_stale_hint(usage_tab):
|
||||
"""Un run en échec suivi d'un run nominal ne doit pas laisser de hint périmé."""
|
||||
usage_tab._last_output_dir = Path("/out")
|
||||
usage_tab._show_failure_hint(RunSummary(total=3, succeeded=2, failed=1))
|
||||
with_hint = len(usage_tab._rsec.winfo_children())
|
||||
|
||||
usage_tab._show_failure_hint(RunSummary(total=3, succeeded=3, failed=0))
|
||||
without_hint = len(usage_tab._rsec.winfo_children())
|
||||
|
||||
assert without_hint == with_hint - 1
|
||||
Reference in New Issue
Block a user