Compare commits

..
Author SHA1 Message Date
Codex 211dd1af1d fix(caldav): ignorer les événements gérés sans UID 2026-09-13 00:13:05 +02:00
9 changed files with 78 additions and 235 deletions
+2 -5
View File
@@ -121,7 +121,6 @@ def fetch_step(
:raises PronoteAuthRotationError: Si une rotation du token d'authentification :raises PronoteAuthRotationError: Si une rotation du token d'authentification
pronotepy est nécessaire : propagée telle quelle jusqu'au pipeline. pronotepy est nécessaire : propagée telle quelle jusqu'au pipeline.
""" """
critical_error: PipelineCriticalError | None = None
try: try:
lessons, school_events = fetcher.fetch_agenda() lessons, school_events = fetcher.fetch_agenda()
target_date = resolve_target_date(today or date.today(), lessons, school_events) target_date = resolve_target_date(today or date.today(), lessons, school_events)
@@ -131,11 +130,9 @@ def fetch_step(
except PronoteAuthRotationError: except PronoteAuthRotationError:
raise raise
except Exception as exc: except Exception as exc:
critical_error = PipelineCriticalError( raise PipelineCriticalError(
f"Récupération Pronote impossible : {redact_exception(exc)}", step="fetch" f"Récupération Pronote impossible : {redact_exception(exc)}", step="fetch"
) ) from None
if critical_error is not None:
raise critical_error from None
messages, warnings = _fetch_optional_messages(fetcher) messages, warnings = _fetch_optional_messages(fetcher)
return ( return (
+5 -8
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from pronote_sync.sources.blog.result import BlogRSSFetchResult from pronote_sync.sources.blog.result import BlogRSSFetchResult
from pronote_sync.sources.blog.rss import BlogRSSClient from pronote_sync.sources.blog.rss import BlogRSSClient
from pronote_sync.sources.blog.state import BlogRSSState from pronote_sync.sources.blog.state import BlogRSSState
from pronote_sync.utils.redaction import redact_exception, redact_secrets from pronote_sync.utils.redaction import redact_exception
def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) -> BlogRSSFetchResult: def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) -> BlogRSSFetchResult:
@@ -24,18 +24,15 @@ def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) ->
""" """
if client is None or state is None: if client is None or state is None:
return BlogRSSFetchResult() return BlogRSSFetchResult()
error_message: str | None = None
try: try:
etag, last_modified = state.get_cache_headers() etag, last_modified = state.get_cache_headers()
result = client.fetch_and_parse( result = client.fetch_and_parse(
known_guids=state.get_known_guids(), etag=etag, last_modified=last_modified known_guids=state.get_known_guids(), etag=etag, last_modified=last_modified
) )
if result.error is not None: if result.error is not None:
error_message = f"Récupération du blog échouée : {redact_secrets(result.error)}" raise RuntimeError(result.error) from None
elif not result.not_modified and not result.articles: if not result.not_modified and not result.articles:
state.update_cache_headers(result.etag, result.last_modified) state.update_cache_headers(result.etag, result.last_modified)
except Exception as exc:
error_message = f"Récupération du blog échouée : {redact_exception(exc)}"
if error_message is not None:
raise RuntimeError(error_message) from None
return result return result
except Exception as exc:
raise RuntimeError(f"Récupération du blog échouée : {redact_exception(exc)}") from None
+9 -26
View File
@@ -97,10 +97,7 @@ class BlogRSSState:
redact_exception(exc), redact_exception(exc),
) )
def _save( def _save(self) -> None:
self,
state: tuple[set[str], str | None, str | None] | None = None,
) -> bool:
"""Sauvegarde l'état dans le fichier JSON de manière atomique. """Sauvegarde l'état dans le fichier JSON de manière atomique.
La sortie est déterministe : ``known_guids`` est trié La sortie est déterministe : ``known_guids`` est trié
@@ -110,30 +107,20 @@ class BlogRSSState:
jamais laisser un fichier partiel en cas d'interruption. En cas jamais laisser un fichier partiel en cas d'interruption. En cas
d'erreur d'écriture, une erreur est journalisée sans être d'erreur d'écriture, une erreur est journalisée sans être
propagée et le fichier temporaire est supprimé. propagée et le fichier temporaire est supprimé.
:param state: État à sauvegarder ; l'état courant est utilisé par défaut.
:return: ``True`` si l'état a été sauvegardé ou si la persistance est désactivée.
:rtype: bool
""" """
if not self._persistence_enabled: if not self._persistence_enabled:
return True return
known_guids, etag, last_modified = state or (
self._known_guids,
self._etag,
self._last_modified,
)
payload = { payload = {
"version": _STATE_VERSION, "version": _STATE_VERSION,
"known_guids": sorted(known_guids), "known_guids": sorted(self._known_guids),
"etag": etag, "etag": self._etag,
"last_modified": last_modified, "last_modified": self._last_modified,
} }
tmp_file = self._state_file.with_suffix(".tmp") tmp_file = self._state_file.with_suffix(".tmp")
try: try:
with open(tmp_file, "w", encoding="utf-8") as handle: with open(tmp_file, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2) json.dump(payload, handle, indent=2)
tmp_file.replace(self._state_file) tmp_file.replace(self._state_file)
return True
except Exception as exc: except Exception as exc:
logger.error( logger.error(
"Impossible d'écrire le fichier d'état blog RSS %s : %s.", "Impossible d'écrire le fichier d'état blog RSS %s : %s.",
@@ -147,7 +134,6 @@ class BlogRSSState:
"Nettoyage du fichier temporaire échoué : %s", "Nettoyage du fichier temporaire échoué : %s",
redact_exception(cleanup_exc), redact_exception(cleanup_exc),
) )
return False
def get_known_guids(self) -> frozenset[str]: def get_known_guids(self) -> frozenset[str]:
"""Renvoie une copie immuable des GUID d'articles déjà connus. """Renvoie une copie immuable des GUID d'articles déjà connus.
@@ -182,13 +168,10 @@ class BlogRSSState:
""" """
if result.not_modified: if result.not_modified:
return return
new_state = ( self._known_guids.update(article.id for article in result.articles)
self._known_guids | {article.id for article in result.articles}, self._etag = result.etag
result.etag, self._last_modified = result.last_modified
result.last_modified, self._save()
)
if self._save(new_state):
self._known_guids, self._etag, self._last_modified = new_state
def get_cache_headers(self) -> tuple[str | None, str | None]: def get_cache_headers(self) -> tuple[str | None, str | None]:
"""Renvoie les en-têtes de cache HTTP mémorisés. """Renvoie les en-têtes de cache HTTP mémorisés.
+2 -5
View File
@@ -218,7 +218,6 @@ class PronoteAuthState:
} }
tmp_file = self._state_file.with_suffix(".tmp") tmp_file = self._state_file.with_suffix(".tmp")
fd: int | None = None fd: int | None = None
write_error: PronoteSyncError | None = None
try: try:
# Nettoie un éventuel fichier temporaire stale laissé par une exécution interrompue. # Nettoie un éventuel fichier temporaire stale laissé par une exécution interrompue.
if tmp_file.exists(): if tmp_file.exists():
@@ -260,12 +259,10 @@ class PronoteAuthState:
"Nettoyage du fichier temporaire d'état d'authentification Pronote échoué : %s", "Nettoyage du fichier temporaire d'état d'authentification Pronote échoué : %s",
redact_exception(cleanup_exc), redact_exception(cleanup_exc),
) )
write_error = PronoteSyncError( raise PronoteSyncError(
f"Impossible d'écrire le fichier d'état d'authentification Pronote " f"Impossible d'écrire le fichier d'état d'authentification Pronote "
f"{redact_secrets(str(self._state_file))}." f"{redact_secrets(str(self._state_file))}."
) ) from None
if write_error is not None:
raise write_error from None
def clear(self) -> None: def clear(self) -> None:
"""Supprime le fichier d'état d'authentification. """Supprime le fichier d'état d'authentification.
+4 -9
View File
@@ -274,9 +274,8 @@ class PronoteFetcher:
f"Impossible de récupérer l'agenda : la source {primary} a échoué" f"Impossible de récupérer l'agenda : la source {primary} a échoué"
) from None ) from None
logger.info("Repli sur %s pour l'agenda.", fallback) logger.info("Repli sur %s pour l'agenda.", fallback)
fallback_result: tuple[list[Lesson], list[SchoolEvent]] | None = None
try: try:
fallback_result = self._fetch_agenda_source(fallback) lessons, school_events = self._fetch_agenda_source(fallback)
except PronoteAuthRotationError: except PronoteAuthRotationError:
raise raise
except Exception as exc: except Exception as exc:
@@ -285,11 +284,10 @@ class PronoteFetcher:
fallback, fallback,
redact_exception(exc), redact_exception(exc),
) )
if fallback_result is None:
raise PipelineCriticalError( raise PipelineCriticalError(
f"Impossible de récupérer l'agenda : les sources {primary} et {fallback} ont échoué" f"Impossible de récupérer l'agenda : les sources {primary}"
f" et {fallback} ont échoué"
) from None ) from None
lessons, school_events = fallback_result
if not lessons: if not lessons:
logger.warning( logger.warning(
"Le repli %s pour l'agenda a retourné un résultat vide après l'échec " "Le repli %s pour l'agenda a retourné un résultat vide après l'échec "
@@ -403,9 +401,8 @@ class PronoteFetcher:
f"Impossible de récupérer les devoirs : la source {primary} a échoué" f"Impossible de récupérer les devoirs : la source {primary} a échoué"
) from None ) from None
logger.info("Repli sur %s pour les devoirs.", fallback) logger.info("Repli sur %s pour les devoirs.", fallback)
fallback_result: list[Homework] | None = None
try: try:
fallback_result = self._fetch_homework_source(fallback, target_date) homeworks = self._fetch_homework_source(fallback, target_date)
except PronoteAuthRotationError: except PronoteAuthRotationError:
raise raise
except Exception as exc: except Exception as exc:
@@ -414,12 +411,10 @@ class PronoteFetcher:
fallback, fallback,
redact_exception(exc), redact_exception(exc),
) )
if fallback_result is None:
raise PipelineCriticalError( raise PipelineCriticalError(
f"Impossible de récupérer les devoirs : les sources {primary}" f"Impossible de récupérer les devoirs : les sources {primary}"
f" et {fallback} ont échoué" f" et {fallback} ont échoué"
) from None ) from None
homeworks = fallback_result
if not homeworks: if not homeworks:
logger.warning( logger.warning(
"Le repli %s pour les devoirs a retourné un résultat vide après " "Le repli %s pour les devoirs a retourné un résultat vide après "
+2 -5
View File
@@ -85,7 +85,6 @@ class JsonTheoreticalAgendaProvider:
self._file_path: str = file_path self._file_path: str = file_path
self._parity_service: WeekParityService | None = parity_service self._parity_service: WeekParityService | None = parity_service
self._holiday_calendar: SchoolHolidayCalendar | None = holiday_calendar self._holiday_calendar: SchoolHolidayCalendar | None = holiday_calendar
load_error: PronoteSyncError | None = None
try: try:
content = Path(file_path).read_text(encoding="utf-8") content = Path(file_path).read_text(encoding="utf-8")
parsed = TheoreticalAgendaFile.model_validate_json(content) parsed = TheoreticalAgendaFile.model_validate_json(content)
@@ -95,11 +94,9 @@ class JsonTheoreticalAgendaProvider:
redact_secrets(str(file_path)), redact_secrets(str(file_path)),
redact_exception(exc), redact_exception(exc),
) )
load_error = PronoteSyncError( raise PronoteSyncError(
f"Le fichier d'agenda théorique est invalide : {redact_secrets(str(file_path))}" f"Le fichier d'agenda théorique est invalide : {redact_secrets(str(file_path))}"
) ) from None
if load_error is not None:
raise load_error from None
self._lessons: tuple[TheoreticalLessonEntry, ...] = parsed.lessons self._lessons: tuple[TheoreticalLessonEntry, ...] = parsed.lessons
if self._parity_service is None and any( if self._parity_service is None and any(
entry.week in ("even", "odd") for entry in self._lessons entry.week in ("even", "odd") for entry in self._lessons
+2 -5
View File
@@ -77,7 +77,6 @@ class SchoolHolidayCalendar:
raise PronoteSyncError( raise PronoteSyncError(
f"Le fichier de vacances scolaires est introuvable : {redact_secrets(str(path))}" f"Le fichier de vacances scolaires est introuvable : {redact_secrets(str(path))}"
) from None ) from None
load_error: PronoteSyncError | None = None
try: try:
data: Any = json.loads(path.read_text(encoding="utf-8")) data: Any = json.loads(path.read_text(encoding="utf-8"))
file_model: SchoolHolidayFile = SchoolHolidayFile.model_validate(data) file_model: SchoolHolidayFile = SchoolHolidayFile.model_validate(data)
@@ -87,11 +86,9 @@ class SchoolHolidayCalendar:
redact_secrets(str(path)), redact_secrets(str(path)),
redact_exception(exc), redact_exception(exc),
) )
load_error = PronoteSyncError( raise PronoteSyncError(
f"Le fichier de vacances scolaires est invalide : {redact_secrets(str(path))}" f"Le fichier de vacances scolaires est invalide : {redact_secrets(str(path))}"
) ) from None
if load_error is not None:
raise load_error from None
self._periods = file_model.periods self._periods = file_model.periods
def is_holiday(self, target_date: date) -> bool: def is_holiday(self, target_date: date) -> bool:
-37
View File
@@ -425,41 +425,4 @@ def test_atomic_save_preserves_on_error(tmp_path: Path) -> None:
assert state.get_known_guids() == frozenset({"original-guid-1", "original-guid-2", "new-guid"}) assert state.get_known_guids() == frozenset({"original-guid-1", "original-guid-2", "new-guid"})
def test_acknowledge_does_not_advance_memory_when_save_fails(
tmp_path: Path,
) -> None:
"""Conserve l'état précédent en mémoire si l'acquittement ne peut pas être sauvegardé.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
state = BlogRSSState(state_file)
state.add_guids(["existing-guid"])
state.update_cache_headers("old-etag", "old-last-modified")
article = BlogArticle(
id="new-guid",
title="Article",
url="https://example.com/article",
published_at=datetime(2026, 9, 12, 8, 0, tzinfo=UTC),
updated_at=None,
category=None,
author=None,
content_html="<p>Contenu</p>",
content_text="Contenu",
)
with patch.object(Path, "replace", side_effect=OSError("replace failed")):
state.acknowledge(
BlogRSSFetchResult(
articles=(article,),
etag="new-etag",
last_modified="new-last-modified",
)
)
assert state.get_known_guids() == frozenset({"existing-guid"})
assert state.get_cache_headers() == ("old-etag", "old-last-modified")
# Ensure trailing newline # Ensure trailing newline
-83
View File
@@ -1,83 +0,0 @@
"""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 == []