From 29cb466595487ed5d2d5151094c0f17eb1c6d224 Mon Sep 17 00:00:00 2001 From: Dom Date: Thu, 25 Jun 2026 16:44:31 +0200 Subject: [PATCH] fix(lea): journalisation client vers fichier (DETTE-021) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup_logging() branche un TimedRotatingFileHandler vers LOG_FILE (rotation quotidienne + rétention 180j, Règlement IA Art.12) + console. Sous pythonw (sans console), basicConfig->stderr était perdu => diagnostic terrain aveugle. main.py appelle setup_logging au démarrage, avec fallback console si le fichier est indisponible (ne jamais empêcher Léa de démarrer). TDD: tests/unit/test_agent_v1_logging.py (3 tests RED->GREEN ; module chargé par chemin pour éviter les imports lourds DETTE-011/013). py_compile main.py OK. refs DETTE-021 Co-Authored-By: Claude Opus 4.8 (1M context) --- agent_v0/agent_v1/logging_setup.py | 56 ++++++++++++++++++++++ agent_v0/agent_v1/main.py | 20 +++++--- tests/unit/test_agent_v1_logging.py | 74 +++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 agent_v0/agent_v1/logging_setup.py create mode 100644 tests/unit/test_agent_v1_logging.py diff --git a/agent_v0/agent_v1/logging_setup.py b/agent_v0/agent_v1/logging_setup.py new file mode 100644 index 000000000..b1d7eb982 --- /dev/null +++ b/agent_v0/agent_v1/logging_setup.py @@ -0,0 +1,56 @@ +"""Journalisation client Léa — DETTE-021. + +Branche un handler **fichier** (`TimedRotatingFileHandler`) sur le logger racine, +en plus de la console. Sans cela, sous `pythonw.exe` (pas de console), les logs +partent sur stderr et sont **perdus** — diagnostic terrain impossible. + +Rotation quotidienne + rétention `retention_days` (Règlement IA Art. 12 : +journalisation automatique + conservation minimum 180 j). +""" +import logging +from logging.handlers import TimedRotatingFileHandler +from pathlib import Path + +_FMT = "%(asctime)s %(levelname)-7s %(name)-25s %(message)s" + + +def setup_logging(log_file, level=logging.INFO, retention_days=180): + """Configure le logging racine : fichier (rotation quotidienne, `retention_days` + fichiers conservés) + console. **Idempotent** : ne réempile pas nos handlers. + + Args: + log_file: chemin du fichier de log (`config.LOG_FILE` en prod). + level: niveau racine (INFO par défaut ; DEBUG géré par l'appelant). + retention_days: nb de fichiers quotidiens conservés (180 = Règlement IA Art. 12). + + Returns: + Le `TimedRotatingFileHandler` créé. + """ + log_file = Path(log_file) + log_file.parent.mkdir(parents=True, exist_ok=True) + + root = logging.getLogger() + root.setLevel(level) + + # Idempotence : retirer nos propres handlers posés par un appel précédent. + for h in list(root.handlers): + if getattr(h, "_lea_managed", False): + h.close() + root.removeHandler(h) + + file_handler = TimedRotatingFileHandler( + str(log_file), when="midnight", backupCount=retention_days, encoding="utf-8" + ) + file_handler.setFormatter(logging.Formatter(_FMT, datefmt="%Y-%m-%d %H:%M:%S")) + file_handler.setLevel(level) + file_handler._lea_managed = True + root.addHandler(file_handler) + + # Console conservée (utile en dev / si lancé avec une console). + console = logging.StreamHandler() + console.setFormatter(logging.Formatter(_FMT, datefmt="%H:%M:%S")) + console.setLevel(level) + console._lea_managed = True + root.addHandler(console) + + return file_handler diff --git a/agent_v0/agent_v1/main.py b/agent_v0/agent_v1/main.py index 2d13fb67c..334604277 100644 --- a/agent_v0/agent_v1/main.py +++ b/agent_v0/agent_v1/main.py @@ -15,7 +15,7 @@ import time import logging import threading from .config import ( - SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS, + SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS, LOG_FILE, SCREEN_RESOLUTION, DPI_SCALE, OS_THEME, API_TOKEN, MAX_SESSION_DURATION_S, STREAMING_ENDPOINT, ) @@ -43,11 +43,19 @@ except (ImportError, ValueError): # Configuration du logging — format structuré et lisible pour un TIM # Niveau de détail : INFO par défaut, DEBUG si RPA_AGENT_DEBUG=1 _log_level = logging.DEBUG if os.environ.get("RPA_AGENT_DEBUG") == "1" else logging.INFO -logging.basicConfig( - level=_log_level, - format="%(asctime)s %(levelname)-7s %(name)-25s %(message)s", - datefmt="%H:%M:%S", -) +# DETTE-021 : journaliser dans un FICHIER (rotation quotidienne + rétention 180 j, +# Règlement IA Art. 12). Sous `pythonw.exe` (sans console), un basicConfig→stderr +# serait perdu. Fallback console si le fichier est indisponible — ne JAMAIS +# empêcher Léa de démarrer pour un problème de log. +try: + from .logging_setup import setup_logging + setup_logging(LOG_FILE, level=_log_level, retention_days=LOG_RETENTION_DAYS) +except Exception: + logging.basicConfig( + level=_log_level, + format="%(asctime)s %(levelname)-7s %(name)-25s %(message)s", + datefmt="%H:%M:%S", + ) # Réduire le bruit de certaines libs for _noisy in ("urllib3", "requests.packages.urllib3", "PIL", "mss"): diff --git a/tests/unit/test_agent_v1_logging.py b/tests/unit/test_agent_v1_logging.py new file mode 100644 index 000000000..61924a557 --- /dev/null +++ b/tests/unit/test_agent_v1_logging.py @@ -0,0 +1,74 @@ +"""TDD — DETTE-021 : journalisation client Léa effective (vers fichier). + +Aujourd'hui `LOG_FILE` est défini (`agent_v0/agent_v1/config.py`) mais jamais +branché ; `basicConfig` écrit sur stderr — perdu car Léa tourne en `pythonw.exe` +(sans console). On veut une fonction `setup_logging()` qui branche un handler +FICHIER avec rotation quotidienne + rétention (Règlement IA Art. 12, 180 j). + +Le module est chargé par chemin (importlib) pour ne dépendre d'aucun import +lourd du package client (cf. DETTE-011/013). +""" +import importlib.util +import logging +from logging.handlers import TimedRotatingFileHandler +from pathlib import Path + +_MOD_PATH = Path(__file__).resolve().parents[2] / "agent_v0" / "agent_v1" / "logging_setup.py" + + +def _load_setup_logging(): + spec = importlib.util.spec_from_file_location("lea_logging_setup", _MOD_PATH) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod.setup_logging + + +def _cleanup_root(): + root = logging.getLogger() + for h in list(root.handlers): + if getattr(h, "_lea_managed", False): + h.close() + root.removeHandler(h) + + +def test_setup_logging_ecrit_dans_le_fichier(tmp_path): + """Les logs doivent atterrir dans LOG_FILE (et plus seulement sur stderr).""" + log_file = tmp_path / "agent_v1.log" + setup_logging = _load_setup_logging() + try: + setup_logging(log_file=log_file, level=logging.INFO) + logging.getLogger("lea.test").info("message de diagnostic") + for h in logging.getLogger().handlers: + h.flush() + assert log_file.exists(), "le fichier de log doit être créé" + assert "message de diagnostic" in log_file.read_text(encoding="utf-8") + finally: + _cleanup_root() + + +def test_setup_logging_rotation_et_retention(tmp_path): + """Rotation quotidienne + rétention configurable (180 j par défaut — Art. 12).""" + log_file = tmp_path / "agent_v1.log" + setup_logging = _load_setup_logging() + try: + setup_logging(log_file=log_file, retention_days=180) + handlers = [h for h in logging.getLogger().handlers + if isinstance(h, TimedRotatingFileHandler)] + assert handlers, "un TimedRotatingFileHandler doit être branché" + assert handlers[0].backupCount == 180 + finally: + _cleanup_root() + + +def test_setup_logging_idempotent(tmp_path): + """Appels répétés n'empilent pas les handlers fichier (pas de doublon).""" + log_file = tmp_path / "agent_v1.log" + setup_logging = _load_setup_logging() + try: + setup_logging(log_file=log_file) + setup_logging(log_file=log_file) + file_handlers = [h for h in logging.getLogger().handlers + if isinstance(h, TimedRotatingFileHandler)] + assert len(file_handlers) == 1, "pas de handler fichier en double" + finally: + _cleanup_root()