"""Régressions sur le contexte des exceptions expurgées.""" from __future__ import annotations import ast import traceback from datetime import date from pathlib import Path import pytest from pronote_sync.errors import PipelineCriticalError from pronote_sync.models.agenda import Lesson, SchoolEvent from pronote_sync.models.homework import Homework from pronote_sync.models.message import Message from pronote_sync.pipeline.steps.fetch import fetch_step class _FailingFetcher: """Fetcher minimal qui expose une erreur externe porteuse d'un secret.""" def fetch_agenda(self) -> tuple[list[Lesson], list[SchoolEvent]]: """Déclenche une erreur externe pendant la récupération critique.""" raise RuntimeError("password=fetch-context-secret") def fetch_homework(self, target_date: date) -> list[Homework]: """Retourne une liste vide pour compléter le protocole du fetcher.""" return [] def fetch_messages(self) -> list[Message]: """Retourne une liste vide pour compléter le protocole du fetcher.""" return [] def fetch_informations(self) -> list[Message]: """Retourne une liste vide pour compléter le protocole du fetcher.""" return [] def test_fetch_step_does_not_retain_external_exception_context() -> None: """Vérifie qu'une erreur critique ne conserve ni secret ni contexte externe.""" with pytest.raises(PipelineCriticalError) as exc_info: fetch_step(_FailingFetcher()) error = exc_info.value formatted = "".join(traceback.format_exception(error)) assert "fetch-context-secret" not in str(error) assert "fetch-context-secret" not in formatted assert error.__cause__ is None assert error.__context__ is None def test_production_raise_from_none_is_never_inside_except() -> None: """Vérifie structurellement que les exceptions expurgées sont levées hors des handlers.""" root = Path(__file__).parents[2] / "pronote_sync" violations: list[str] = [] class Visitor(ast.NodeVisitor): """Collecte les levées ``from None`` imbriquées dans un handler.""" def __init__(self) -> None: self._inside_except = False def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None: """Visite un handler en marquant son périmètre lexical.""" previous = self._inside_except self._inside_except = True self.generic_visit(node) self._inside_except = previous def visit_Raise(self, node: ast.Raise) -> None: """Signale une levée ``from None`` dans un handler.""" if ( self._inside_except and isinstance(node.cause, ast.Constant) and node.cause.value is None ): violations.append(f"{path}:{node.lineno}") self.generic_visit(node) for path in sorted(root.rglob("*.py")): Visitor().visit(ast.parse(path.read_text(encoding="utf-8"))) assert violations == []