docs(beta): plan d'implémentation 1a — socle sûreté & chaîne prod GUI V6
Plan bite-sized TDD pour les chantiers A/B/E1 : fail-close PII (P0-1), URL portail (P0-2), binding licence souple (P0-6), log fichier V6 (E1), flag frozen ONNX (P0-5), instance unique + mutex installeur (P0-7). 6 tâches, code complet, tests unitaires. Plans 1b (gating cœur), 1c (UI), 2 (diagnostics), 3 (build/release) à suivre. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
649
docs/superpowers/plans/2026-06-25-gui-v6-beta-plan-1a-socle.md
Normal file
649
docs/superpowers/plans/2026-06-25-gui-v6-beta-plan-1a-socle.md
Normal file
@@ -0,0 +1,649 @@
|
||||
# GUI V6 bêta — Plan 1a : socle sûreté & chaîne prod
|
||||
|
||||
> **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:** Rendre la GUI V6 sûre (fail-close PII) et réellement connectée à la chaîne de prod (portail, binding licence, stabilité frozen, log fichier) — sans toucher au cœur de détection ni à l'UI.
|
||||
|
||||
**Architecture:** 6 corrections ciblées, toutes dans `gui_v6/` + l'entrée frozen `Pseudonymisation_Gui_V6.py`. Chaque fix est testable en `pytest` sur Linux (sauf confirmation frozen de P0-5/P0-7 au smoke EXE, hors de ce plan). On suit le contrat existant : factories injectables, sessions HTTP injectables, aucun appel réseau en test.
|
||||
|
||||
**Tech Stack:** Python 3.10-3.12, pytest, `logging` stdlib + loguru (best-effort), `ctypes`/`msvcrt` (Windows), `fcntl` (POSIX).
|
||||
|
||||
**Référence spec :** `docs/superpowers/specs/2026-06-25-gui-v6-beta-prod-design.md` (chantiers A, B, E1).
|
||||
|
||||
---
|
||||
|
||||
### Task 1 : URL portail réelle (P0-2)
|
||||
|
||||
**Files:**
|
||||
- Modify: `gui_v6/app.py:12-24` (imports), `:41` (défaut client), `:199` (fallback télémétrie)
|
||||
- Test: `tests/unit/test_gui_v6_portal_url.py`
|
||||
|
||||
- [ ] **Step 1 : Écrire le test qui échoue**
|
||||
|
||||
```python
|
||||
# tests/unit/test_gui_v6_portal_url.py
|
||||
import pytest
|
||||
|
||||
|
||||
def test_default_portal_url_is_prod(monkeypatch):
|
||||
monkeypatch.delenv("ANON_PORTAL_URL", raising=False)
|
||||
from gui_v6.app import DEFAULT_PORTAL_URL, resolve_portal_url
|
||||
assert DEFAULT_PORTAL_URL == "https://app.aivanov.eu"
|
||||
assert resolve_portal_url() == "https://app.aivanov.eu"
|
||||
|
||||
|
||||
def test_portal_url_env_override(monkeypatch):
|
||||
monkeypatch.setenv("ANON_PORTAL_URL", "http://localhost:8088")
|
||||
from gui_v6.app import resolve_portal_url
|
||||
assert resolve_portal_url() == "http://localhost:8088"
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer le test pour le voir échouer**
|
||||
|
||||
Run: `.venv/bin/pytest tests/unit/test_gui_v6_portal_url.py -v`
|
||||
Expected: FAIL — `ImportError: cannot import name 'DEFAULT_PORTAL_URL'`.
|
||||
|
||||
- [ ] **Step 3 : Implémenter**
|
||||
|
||||
Dans `gui_v6/app.py`, ajouter `import os` en tête (sous `from __future__ import annotations`), puis après les imports (avant `_TABS`) :
|
||||
|
||||
```python
|
||||
DEFAULT_PORTAL_URL = "https://app.aivanov.eu"
|
||||
|
||||
|
||||
def resolve_portal_url() -> str:
|
||||
"""URL du portail : env ``ANON_PORTAL_URL`` sinon défaut prod."""
|
||||
return os.environ.get("ANON_PORTAL_URL", DEFAULT_PORTAL_URL)
|
||||
```
|
||||
|
||||
Remplacer la ligne 41 :
|
||||
```python
|
||||
self._license_client = license_client or LicenseClient(resolve_portal_url())
|
||||
```
|
||||
|
||||
Remplacer le fallback de la ligne 199 :
|
||||
```python
|
||||
base_url = getattr(self._license_client, "_base_url", "") or resolve_portal_url()
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer le test pour le voir passer**
|
||||
|
||||
Run: `.venv/bin/pytest tests/unit/test_gui_v6_portal_url.py -v`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add gui_v6/app.py tests/unit/test_gui_v6_portal_url.py
|
||||
git commit -m "fix(gui): connecter la GUI V6 au portail prod (P0-2, plus localhost)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2 : Fail-close si le NER obligatoire est indisponible (P0-1)
|
||||
|
||||
**Files:**
|
||||
- Modify: `gui_v6/engine_bridge.py:36-41` (nouvelle exception), `:222-231` (`process_fn`)
|
||||
- Test: `tests/unit/test_gui_v6_engine_failclose.py`
|
||||
|
||||
- [ ] **Step 1 : Écrire le test qui échoue**
|
||||
|
||||
```python
|
||||
# tests/unit/test_gui_v6_engine_failclose.py
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gui_v6.engine_bridge import (
|
||||
EngineSettings,
|
||||
EngineUnavailableError,
|
||||
NerManagers,
|
||||
make_process_fn,
|
||||
)
|
||||
|
||||
|
||||
def _managers_with_broken_camembert(settings):
|
||||
def boom():
|
||||
raise RuntimeError("model.onnx absent")
|
||||
|
||||
return NerManagers(
|
||||
settings,
|
||||
factories={"camembert": boom, "eds": boom, "gliner": boom},
|
||||
caps_provider=lambda: {},
|
||||
)
|
||||
|
||||
|
||||
def test_process_fn_raises_when_mandatory_ner_unavailable():
|
||||
settings = EngineSettings(use_local_ner=True)
|
||||
managers = _managers_with_broken_camembert(settings)
|
||||
called = {"engine": False}
|
||||
|
||||
def fake_engine(*a, **k):
|
||||
called["engine"] = True
|
||||
return {"pdf": "x"}
|
||||
|
||||
fn = make_process_fn(settings, managers=managers, engine=fake_engine)
|
||||
with pytest.raises(EngineUnavailableError):
|
||||
fn(Path("doc.pdf"), Path("/tmp/out"))
|
||||
# Le moteur ne doit JAMAIS être appelé → aucune sortie possible.
|
||||
assert called["engine"] is False
|
||||
|
||||
|
||||
def test_process_fn_runs_when_ner_ok():
|
||||
settings = EngineSettings(use_local_ner=True)
|
||||
managers = NerManagers(
|
||||
settings,
|
||||
factories={"camembert": lambda: object(), "eds": lambda: None, "gliner": lambda: None},
|
||||
caps_provider=lambda: {},
|
||||
)
|
||||
fn = make_process_fn(settings, managers=managers, engine=lambda *a, **k: {"pdf": "ok"})
|
||||
assert fn(Path("d.pdf"), Path("/tmp/out")) == {"pdf": "ok"}
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer le test pour le voir échouer**
|
||||
|
||||
Run: `.venv/bin/pytest tests/unit/test_gui_v6_engine_failclose.py -v`
|
||||
Expected: FAIL — `ImportError: cannot import name 'EngineUnavailableError'`.
|
||||
|
||||
- [ ] **Step 3 : Implémenter**
|
||||
|
||||
Dans `gui_v6/engine_bridge.py`, après la classe `ManagerState` (l.41) :
|
||||
|
||||
```python
|
||||
class EngineUnavailableError(RuntimeError):
|
||||
"""Levée quand un moteur de détection OBLIGATOIRE n'a pas pu être chargé.
|
||||
|
||||
Garantit le fail-close : on refuse de produire une sortie plutôt que de
|
||||
livrer un document potentiellement non anonymisé (aligné sur le code 3 du CLI).
|
||||
"""
|
||||
```
|
||||
|
||||
Remplacer le corps de `process_fn` (l.222-231) :
|
||||
|
||||
```python
|
||||
def process_fn(doc_path: Path, out_dir: Path) -> dict:
|
||||
if settings.use_local_ner:
|
||||
state = managers.ensure_loaded()
|
||||
if state == ManagerState.UNAVAILABLE:
|
||||
raise EngineUnavailableError(
|
||||
"Modèle de détection obligatoire (CamemBERT-bio) indisponible — "
|
||||
"traitement refusé pour éviter une anonymisation incomplète."
|
||||
)
|
||||
kwargs = build_engine_kwargs(settings, managers)
|
||||
run = engine
|
||||
if run is None:
|
||||
from anonymizer_core_refactored_onnx import process_document
|
||||
|
||||
run = process_document
|
||||
return run(doc_path, out_dir, **kwargs)
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer le test pour le voir passer**
|
||||
|
||||
Run: `.venv/bin/pytest tests/unit/test_gui_v6_engine_failclose.py -v`
|
||||
Expected: PASS (2 tests). Le runner (`processing_runner._run_impl:221`) attrape déjà toute `Exception` → le doc est compté **échec** et non livré.
|
||||
|
||||
- [ ] **Step 5 : Vérifier la non-régression du runner**
|
||||
|
||||
Run: `.venv/bin/pytest tests/unit/test_gui_v6_processing_runner.py -v`
|
||||
Expected: PASS (inchangé).
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add gui_v6/engine_bridge.py tests/unit/test_gui_v6_engine_failclose.py
|
||||
git commit -m "fix(gui): fail-close si CamemBERT-bio indisponible (P0-1, anti-fuite PII)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3 : Binding licence ↔ poste (souple, affichage) (P0-6)
|
||||
|
||||
**Files:**
|
||||
- Modify: `gui_v6/app.py` (imports + `_safe_local_status`), nouvelle fonction `bound_local_status`
|
||||
- Test: `tests/unit/test_gui_v6_license_binding.py`
|
||||
|
||||
- [ ] **Step 1 : Écrire le test qui échoue**
|
||||
|
||||
```python
|
||||
# tests/unit/test_gui_v6_license_binding.py
|
||||
from gui_v6.app import bound_local_status
|
||||
from gui_v6.license_client import LicenseStatus
|
||||
|
||||
|
||||
def test_binding_flags_other_machine():
|
||||
st = LicenseStatus(valid=True, status="active", machine_id="AAAA1111")
|
||||
out = bound_local_status(st, "BBBB2222")
|
||||
assert out.valid is False
|
||||
assert out.status == "autre_poste"
|
||||
assert "autre poste" in out.message.lower()
|
||||
|
||||
|
||||
def test_binding_ok_same_machine():
|
||||
st = LicenseStatus(valid=True, status="active", machine_id="AAAA1111")
|
||||
out = bound_local_status(st, "AAAA1111")
|
||||
assert out.valid is True
|
||||
assert out.status == "active"
|
||||
|
||||
|
||||
def test_binding_noop_without_machine_id():
|
||||
# licence locale sans machine_id (ancien payload) → inchangée, pas de blocage.
|
||||
st = LicenseStatus(valid=True, status="active", machine_id=None)
|
||||
assert bound_local_status(st, "AAAA1111").valid is True
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer le test pour le voir échouer**
|
||||
|
||||
Run: `.venv/bin/pytest tests/unit/test_gui_v6_license_binding.py -v`
|
||||
Expected: FAIL — `ImportError: cannot import name 'bound_local_status'`.
|
||||
|
||||
- [ ] **Step 3 : Implémenter**
|
||||
|
||||
Dans `gui_v6/app.py`, ajouter en tête l'import `from gui_v6.machine_id import default_machine_id` (sous l'import de `license_client`). Ajouter au niveau module (après `resolve_portal_url`) :
|
||||
|
||||
```python
|
||||
def bound_local_status(status: LicenseStatus, local_machine_id: str) -> LicenseStatus:
|
||||
"""Annoter le statut licence selon le binding poste.
|
||||
|
||||
Souple (décision D1) : on N'EMPÊCHE PAS le traitement. Si la licence locale
|
||||
est valide mais liée à un autre ``machine_id`` que le poste courant (ex.
|
||||
``license.json`` copié), on le **signale** par un statut non valide d'affichage.
|
||||
"""
|
||||
if status.valid and status.machine_id and status.machine_id != local_machine_id:
|
||||
return LicenseStatus(
|
||||
valid=False,
|
||||
status="autre_poste",
|
||||
message="Licence liée à un autre poste",
|
||||
expires_at=status.expires_at,
|
||||
grace_days=status.grace_days,
|
||||
machine_id=status.machine_id,
|
||||
license_ref=status.license_ref,
|
||||
)
|
||||
return status
|
||||
```
|
||||
|
||||
Remplacer `_safe_local_status` (l.81-85) :
|
||||
|
||||
```python
|
||||
def _safe_local_status(self) -> LicenseStatus:
|
||||
try:
|
||||
status = self._license_client.local_status()
|
||||
return bound_local_status(status, default_machine_id())
|
||||
except Exception:
|
||||
return LicenseStatus.unavailable()
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer le test pour le voir passer**
|
||||
|
||||
Run: `.venv/bin/pytest tests/unit/test_gui_v6_license_binding.py -v`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add gui_v6/app.py tests/unit/test_gui_v6_license_binding.py
|
||||
git commit -m "feat(gui): binding licence-poste souple (P0-6/D-20.4, affichage sans blocage)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4 : Log fichier V6 à chemin connu (E1)
|
||||
|
||||
**Files:**
|
||||
- Create: `gui_v6/logging_setup.py`
|
||||
- Test: `tests/unit/test_gui_v6_logging_setup.py`
|
||||
|
||||
- [ ] **Step 1 : Écrire le test qui échoue**
|
||||
|
||||
```python
|
||||
# tests/unit/test_gui_v6_logging_setup.py
|
||||
import logging
|
||||
|
||||
|
||||
def test_setup_file_logging_writes_to_known_path(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
|
||||
from gui_v6.logging_setup import setup_file_logging
|
||||
|
||||
log_path = setup_file_logging()
|
||||
assert log_path.parent.exists()
|
||||
logging.getLogger("test.e1").warning("ligne-temoin-42")
|
||||
for h in logging.getLogger().handlers:
|
||||
h.flush()
|
||||
assert "ligne-temoin-42" in log_path.read_text(encoding="utf-8")
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer le test pour le voir échouer**
|
||||
|
||||
Run: `.venv/bin/pytest tests/unit/test_gui_v6_logging_setup.py -v`
|
||||
Expected: FAIL — `ModuleNotFoundError: No module named 'gui_v6.logging_setup'`.
|
||||
|
||||
- [ ] **Step 3 : Implémenter**
|
||||
|
||||
```python
|
||||
# gui_v6/logging_setup.py
|
||||
"""Configuration du log fichier de la GUI V6 (E1).
|
||||
|
||||
Sans ceci, la GUI frozen fenêtrée (sans console) perd ses logs de diagnostic.
|
||||
Le log est posé dans le même répertoire applicatif que la licence
|
||||
(``%LOCALAPPDATA%/Aivanov/Anonymisation``) pour faciliter sa récupération (E2/E3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
_CONFIGURED = False
|
||||
|
||||
|
||||
def _app_data_dir() -> Path:
|
||||
base = os.environ.get("LOCALAPPDATA")
|
||||
if base:
|
||||
root = Path(base)
|
||||
else: # Linux/dev
|
||||
root = Path.home() / ".local" / "share"
|
||||
return root / "Aivanov" / "Anonymisation"
|
||||
|
||||
|
||||
def log_file_path() -> Path:
|
||||
return _app_data_dir() / "logs" / "anonymisation.log"
|
||||
|
||||
|
||||
def setup_file_logging() -> Path:
|
||||
"""Configure un handler fichier rotatif sur le logger racine. Idempotent."""
|
||||
global _CONFIGURED
|
||||
path = log_file_path()
|
||||
if _CONFIGURED:
|
||||
return path
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = RotatingFileHandler(
|
||||
str(path), maxBytes=2_000_000, backupCount=3, encoding="utf-8"
|
||||
)
|
||||
handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
)
|
||||
root = logging.getLogger()
|
||||
root.setLevel(logging.INFO)
|
||||
root.addHandler(handler)
|
||||
# Best-effort : si le cœur utilise loguru, on ajoute aussi un sink fichier.
|
||||
try:
|
||||
from loguru import logger as _loguru
|
||||
|
||||
_loguru.add(str(path), rotation="2 MB", retention=3, encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
_CONFIGURED = True
|
||||
return path
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer le test pour le voir passer**
|
||||
|
||||
Run: `.venv/bin/pytest tests/unit/test_gui_v6_logging_setup.py -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add gui_v6/logging_setup.py tests/unit/test_gui_v6_logging_setup.py
|
||||
git commit -m "feat(gui): log fichier rotatif V6 à chemin connu (E1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5 : Stabilité frozen — flag legacy ONNX au plus tôt (P0-5)
|
||||
|
||||
**Files:**
|
||||
- Modify: `Pseudonymisation_Gui_V6.py:12-15` (en-tête) + `main` (appel logging)
|
||||
- Test: `tests/unit/test_gui_v6_entry_frozen_flag.py`
|
||||
|
||||
- [ ] **Step 1 : Écrire le test qui échoue**
|
||||
|
||||
```python
|
||||
# tests/unit/test_gui_v6_entry_frozen_flag.py
|
||||
import os
|
||||
import importlib
|
||||
|
||||
|
||||
def test_entry_sets_legacy_onnx_flag_on_import(monkeypatch):
|
||||
monkeypatch.delenv("ANON_SKIP_LEGACY_ONNX_MANAGER", raising=False)
|
||||
import Pseudonymisation_Gui_V6 as entry
|
||||
importlib.reload(entry)
|
||||
assert os.environ.get("ANON_SKIP_LEGACY_ONNX_MANAGER") == "1"
|
||||
|
||||
|
||||
def test_entry_does_not_override_explicit_flag(monkeypatch):
|
||||
monkeypatch.setenv("ANON_SKIP_LEGACY_ONNX_MANAGER", "0")
|
||||
import Pseudonymisation_Gui_V6 as entry
|
||||
importlib.reload(entry)
|
||||
assert os.environ.get("ANON_SKIP_LEGACY_ONNX_MANAGER") == "0"
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer le test pour le voir échouer**
|
||||
|
||||
Run: `.venv/bin/pytest tests/unit/test_gui_v6_entry_frozen_flag.py -v`
|
||||
Expected: FAIL — le flag n'est pas posé.
|
||||
|
||||
- [ ] **Step 3 : Implémenter**
|
||||
|
||||
Dans `Pseudonymisation_Gui_V6.py`, juste après `import sys` (l.14), **avant toute autre logique** :
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
# Frozen Windows : désactiver le manager ONNX legacy AVANT tout import du cœur,
|
||||
# pour éviter « cannot load module more than once per process » (hotfix CLI 6c6f653).
|
||||
os.environ.setdefault("ANON_SKIP_LEGACY_ONNX_MANAGER", "1")
|
||||
```
|
||||
|
||||
Dans `main()` (avant `from gui_v6.app import AnonymisationApp`, l.55), initialiser le log fichier :
|
||||
|
||||
```python
|
||||
from gui_v6.logging_setup import setup_file_logging
|
||||
|
||||
setup_file_logging()
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer le test pour le voir passer**
|
||||
|
||||
Run: `.venv/bin/pytest tests/unit/test_gui_v6_entry_frozen_flag.py -v`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 5 : Vérifier le self-test**
|
||||
|
||||
Run: `.venv/bin/python Pseudonymisation_Gui_V6.py --self-test`
|
||||
Expected: `GUI V6 self-test OK`, exit 0.
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add Pseudonymisation_Gui_V6.py tests/unit/test_gui_v6_entry_frozen_flag.py
|
||||
git commit -m "fix(gui): flag legacy ONNX + log fichier dès l'entrée frozen (P0-5/E1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6 : Instance unique + mutex partagé installeur (P0-7)
|
||||
|
||||
**Files:**
|
||||
- Create: `gui_v6/single_instance.py`
|
||||
- Modify: `Pseudonymisation_Gui_V6.py:main`
|
||||
- Test: `tests/unit/test_gui_v6_single_instance.py`
|
||||
|
||||
- [ ] **Step 1 : Écrire le test qui échoue**
|
||||
|
||||
```python
|
||||
# tests/unit/test_gui_v6_single_instance.py
|
||||
import pytest
|
||||
|
||||
from gui_v6.single_instance import (
|
||||
APP_MUTEX_NAME,
|
||||
AlreadyRunningError,
|
||||
SingleInstance,
|
||||
)
|
||||
|
||||
|
||||
def test_mutex_name_is_stable():
|
||||
# Nom partagé avec l'installeur (Inno AppMutex). Ne pas changer sans MAJ .iss.
|
||||
assert APP_MUTEX_NAME == "AivanonymAnonymisationV6"
|
||||
|
||||
|
||||
def test_second_instance_is_rejected(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
|
||||
first = SingleInstance()
|
||||
first.acquire()
|
||||
try:
|
||||
with pytest.raises(AlreadyRunningError):
|
||||
SingleInstance().acquire()
|
||||
finally:
|
||||
first.release()
|
||||
|
||||
|
||||
def test_release_allows_reacquire(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
|
||||
a = SingleInstance()
|
||||
a.acquire()
|
||||
a.release()
|
||||
b = SingleInstance()
|
||||
b.acquire() # ne lève pas
|
||||
b.release()
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer le test pour le voir échouer**
|
||||
|
||||
Run: `.venv/bin/pytest tests/unit/test_gui_v6_single_instance.py -v`
|
||||
Expected: FAIL — `ModuleNotFoundError: No module named 'gui_v6.single_instance'`.
|
||||
|
||||
- [ ] **Step 3 : Implémenter**
|
||||
|
||||
```python
|
||||
# gui_v6/single_instance.py
|
||||
"""Protection multi-instance de la GUI V6 (P0-7).
|
||||
|
||||
- Windows (frozen) : mutex nommé kernel via ctypes — c'est CE nom que l'installeur
|
||||
Inno détecte (``AppMutex``) pour fermer l'app avant une mise à jour (D8).
|
||||
- POSIX (dev/test) : verrou ``fcntl`` exclusif sur un fichier dans le dossier app.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Nom partagé avec installer/Anonymisation.iss (AppMutex). NE PAS modifier seul.
|
||||
APP_MUTEX_NAME = "AivanonymAnonymisationV6"
|
||||
|
||||
|
||||
class AlreadyRunningError(RuntimeError):
|
||||
"""Une autre instance de l'application est déjà en cours d'exécution."""
|
||||
|
||||
|
||||
def _lock_dir() -> Path:
|
||||
base = os.environ.get("LOCALAPPDATA")
|
||||
root = Path(base) if base else Path.home() / ".local" / "share"
|
||||
d = root / "Aivanov" / "Anonymisation"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
class SingleInstance:
|
||||
def __init__(self) -> None:
|
||||
self._handle = None # mutex Windows
|
||||
self._fh = None # file handle POSIX
|
||||
|
||||
def acquire(self) -> None:
|
||||
if sys.platform.startswith("win"):
|
||||
self._acquire_windows()
|
||||
else:
|
||||
self._acquire_posix()
|
||||
|
||||
def _acquire_windows(self) -> None: # pragma: no cover (exécuté sur Windows)
|
||||
import ctypes
|
||||
|
||||
ERROR_ALREADY_EXISTS = 183
|
||||
handle = ctypes.windll.kernel32.CreateMutexW(None, False, APP_MUTEX_NAME)
|
||||
if not handle or ctypes.windll.kernel32.GetLastError() == ERROR_ALREADY_EXISTS:
|
||||
raise AlreadyRunningError("L'application est déjà ouverte.")
|
||||
self._handle = handle
|
||||
|
||||
def _acquire_posix(self) -> None:
|
||||
import fcntl
|
||||
|
||||
path = _lock_dir() / "instance.lock"
|
||||
fh = open(path, "w")
|
||||
try:
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
fh.close()
|
||||
raise AlreadyRunningError("L'application est déjà ouverte.")
|
||||
self._fh = fh
|
||||
|
||||
def release(self) -> None:
|
||||
if self._handle is not None: # pragma: no cover
|
||||
import ctypes
|
||||
|
||||
ctypes.windll.kernel32.CloseHandle(self._handle)
|
||||
self._handle = None
|
||||
if self._fh is not None:
|
||||
self._fh.close()
|
||||
self._fh = None
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer le test pour le voir passer**
|
||||
|
||||
Run: `.venv/bin/pytest tests/unit/test_gui_v6_single_instance.py -v`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5 : Câbler dans l'entrée**
|
||||
|
||||
Dans `Pseudonymisation_Gui_V6.py:main`, après `setup_file_logging()` et avant `AnonymisationApp()` :
|
||||
|
||||
```python
|
||||
from gui_v6.single_instance import AlreadyRunningError, SingleInstance
|
||||
|
||||
guard = SingleInstance()
|
||||
try:
|
||||
guard.acquire()
|
||||
except AlreadyRunningError:
|
||||
try:
|
||||
import tkinter.messagebox as mb
|
||||
|
||||
mb.showinfo("Anonymisation", "L'application est déjà ouverte.")
|
||||
except Exception:
|
||||
print("L'application est déjà ouverte.")
|
||||
return 0
|
||||
try:
|
||||
application = AnonymisationApp()
|
||||
application.mainloop()
|
||||
finally:
|
||||
guard.release()
|
||||
return 0
|
||||
```
|
||||
|
||||
(Remplace les lignes `application = AnonymisationApp()` / `application.mainloop()` / `return 0` existantes.)
|
||||
|
||||
- [ ] **Step 6 : Vérifier self-test + suite GUI V6**
|
||||
|
||||
Run: `.venv/bin/python Pseudonymisation_Gui_V6.py --self-test && .venv/bin/pytest tests/unit/ -k gui_v6 -q`
|
||||
Expected: `GUI V6 self-test OK` + suite verte.
|
||||
|
||||
- [ ] **Step 7 : Commit**
|
||||
|
||||
```bash
|
||||
git add gui_v6/single_instance.py Pseudonymisation_Gui_V6.py tests/unit/test_gui_v6_single_instance.py
|
||||
git commit -m "feat(gui): instance unique + mutex partagé installeur (P0-7)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review (couverture spec chantiers A/B/E1)
|
||||
- P0-1 fail-close → Task 2 ✓ · P0-2 URL → Task 1 ✓ · P0-5 frozen flag → Task 5 ✓ ·
|
||||
P0-6 binding → Task 3 ✓ · P0-7 lock+mutex → Task 6 ✓ · E1 log fichier → Task 4 (+ câblage Task 5) ✓.
|
||||
- Hors de ce plan (par décision de découpe) : P1-2 (Plan 1b), P1-1/3/4/5 + P2 (Plan 1c), diagnostics
|
||||
E2-E4 (Plan 2), build/release C+F (Plan 3).
|
||||
- Cohérence types : `EngineUnavailableError`, `ManagerState.UNAVAILABLE`, `LicenseStatus(machine_id=…)`,
|
||||
`default_machine_id()`, `setup_file_logging()`, `SingleInstance/APP_MUTEX_NAME` — tous définis ici et
|
||||
réutilisés de façon cohérente.
|
||||
- À confirmer au smoke EXE (Plan 3) : P0-5 (crash frozen réel) et P0-7 (mutex Windows).
|
||||
Reference in New Issue
Block a user