fix(lea): journalisation client vers fichier (DETTE-021)
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) <noreply@anthropic.com>
This commit is contained in:
56
agent_v0/agent_v1/logging_setup.py
Normal file
56
agent_v0/agent_v1/logging_setup.py
Normal file
@@ -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
|
||||
@@ -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"):
|
||||
|
||||
74
tests/unit/test_agent_v1_logging.py
Normal file
74
tests/unit/test_agent_v1_logging.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user