fix(M4): pronote_url + account_type et parsing iCal (STATUS:CANCELLED + blocs multi-dates)

Configuration (D1+D3) :
- PronoteSettings : ajout pronote_url (str | None) et account_type
  (Literal student/parent, défaut parent)

Parsing iCal (Fix 4+7) :
- parse_ical : détection STATUS:CANCELLED en plus de CATEGORIES
- parse_body : list[tuple[date, str]] au lieu de dict[date, str]
  pour préserver les blocs multiples à la même date
- parse_homework_blocks : adapté aux listes

Co-authored-by: opencode/coder <coder@agents.invalid>
This commit is contained in:
2026-09-06 15:03:54 +02:00
parent e0cd073f9a
commit 1b550aa818
6 changed files with 578 additions and 198 deletions

View File

@@ -3,11 +3,14 @@
Ce module fournit l'enum :class:`AgendaSource`, le protocole
:class:`PronoteFetcherProtocol` consommé par le pipeline ainsi que la
classe :class:`PronoteFetcher` qui sélectionne la source selon la
configuration (``PRONOTE_AGENDA_SOURCE`` / ``PRONOTE_HOMEWORK_SOURCE``)
avec repli automatique iCal → pronotepy en mode ``auto``. Les messages
configuration (``PRONOTE_AGENDA_SOURCE`` / ``PRONOTE_HOMEWORK_SOURCE``).
La logique applique un repli **unique** : une source primaire est
essayée en premier et, en cas d'échec, une seule source de repli (jamais
réciproque ni itératif) est essayée si elle est configurée. Les messages
et informations proviennent toujours de pronotepy. Toutes les erreurs
sont journalisées avec des secrets masqués via
:func:`~pronote_sync.utils.redaction.redact_exception`.
:func:`~pronote_sync.utils.redaction.redact_exception` ; les exceptions
d'origine ne sont jamais chaînées (``from None``).
"""
from __future__ import annotations
@@ -15,7 +18,7 @@ from __future__ import annotations
import logging
from datetime import date, timedelta
from enum import StrEnum
from typing import Protocol
from typing import Literal, Protocol
from pronote_sync.config.settings import Settings
from pronote_sync.errors import PipelineCriticalError
@@ -28,6 +31,8 @@ from pronote_sync.utils.redaction import redact_exception
logger = logging.getLogger(__name__)
_SourceName = Literal["ical", "pronotepy"]
class AgendaSource(StrEnum):
"""Source configurée pour la récupération de l'agenda et des devoirs."""
@@ -77,12 +82,13 @@ class PronoteFetcherProtocol(Protocol):
class PronoteFetcher:
"""Récupère les données Pronote via iCal ou pronotepy avec repli.
"""Récupère les données Pronote via iCal ou pronotepy avec repli unique.
Unifie les sources iCal et pronotepy selon la source configurée
(``agenda_source`` / ``homework_source``) : en mode ``AUTO``, le flux
iCal est essayé en premier et pronotepy sert de repli. Les messages et
informations proviennent toujours de pronotepy.
(``agenda_source`` / ``homework_source``) : la source primaire est
essayée en premier et, si elle échoue, une seule source de repli est
essayée lorsqu'elle est configurée. Les messages et informations
proviennent toujours de pronotepy.
"""
def __init__(self, settings: Settings, pronote_client: PronoteClientProtocol) -> None:
@@ -105,6 +111,29 @@ class PronoteFetcher:
end = today + timedelta(days=self._settings.app.sync_future_days)
return start, end
def _is_ical_configured(self) -> bool:
"""Vérifie que la source iCal est configurée.
:return: ``True`` si ``ical_url`` est défini, ``False`` sinon.
:rtype: bool
"""
return self._settings.pronote.ical_url is not None
def _is_pronotepy_configured(self) -> bool:
"""Vérifie que la source pronotepy est entièrement configurée.
:return: ``True`` si ``pronote_url``, ``username``, ``password``
et ``ent`` sont tous définis, ``False`` sinon.
:rtype: bool
"""
pronote = self._settings.pronote
return (
pronote.pronote_url is not None
and pronote.username is not None
and pronote.password is not None
and pronote.ent is not None
)
def _fetch_agenda_ical(self) -> tuple[list[Lesson], list[SchoolEvent]]:
"""Récupère l'agenda depuis le flux iCal.
@@ -129,70 +158,101 @@ class PronoteFetcher:
:return: Tuple ``(cours, événements scolaires)``.
:rtype: tuple[list[Lesson], list[SchoolEvent]]
:raises pronotepy.PronoteAPIError: Si l'API Pronote échoue.
:raises ValueError: Si la configuration ou l'ENT est invalide.
:raises requests.RequestException: Si une requête réseau échoue.
:raises ConnectionError: Si la connexion réseau échoue.
:raises TimeoutError: Si la requête réseau expire.
"""
start, end = self._fetch_window()
lessons, _ = self._pronote_client.get_agenda_fallback(start, end)
lessons = self._pronote_client.get_lessons(start, end)
return lessons, []
def _agenda_sources(self) -> tuple[_SourceName, _SourceName | None]:
"""Sélectionne la source primaire et le repli unique pour l'agenda.
En mode ``AUTO``, iCal est primaire si ``ical_url`` est configuré
(repli pronotepy si la configuration pronotepy est complète),
sinon pronotepy sans repli. Les modes explicites ``ICAL`` et
``PRONOTEPY`` désignent la source primaire et tentent l'autre
source en repli unique si elle est configurée.
:return: Tuple ``(source primaire, source de repli ou ``None``)``.
:rtype: tuple[_SourceName, _SourceName | None]
:raises PipelineCriticalError: Si aucune source n'est configurée en mode ``AUTO``.
"""
source = AgendaSource(self._settings.pronote.agenda_source)
if source is AgendaSource.ICAL:
return "ical", "pronotepy" if self._is_pronotepy_configured() else None
if source is AgendaSource.PRONOTEPY:
return "pronotepy", "ical" if self._is_ical_configured() else None
if self._is_ical_configured():
return "ical", "pronotepy" if self._is_pronotepy_configured() else None
if self._is_pronotepy_configured():
return "pronotepy", None
raise PipelineCriticalError(
"Impossible de récupérer l'agenda : ni la source iCal ni pronotepy n'est configurée"
) from None
def _fetch_agenda_source(self, name: _SourceName) -> tuple[list[Lesson], list[SchoolEvent]]:
"""Récupère l'agenda depuis la source nommée.
:param name: Nom de la source (``"ical"`` ou ``"pronotepy"``).
:return: Tuple ``(cours, événements scolaires)``.
:rtype: tuple[list[Lesson], list[SchoolEvent]]
"""
if name == "ical":
return self._fetch_agenda_ical()
return self._fetch_agenda_pronotepy()
def fetch_agenda(self) -> tuple[list[Lesson], list[SchoolEvent]]:
"""Récupère les cours et les événements scolaires selon la source configurée.
En mode ``AUTO``, iCal est essayé en premier et pronotepy sert de
repli ; si les deux sources échouent, une erreur critique est levée.
La source primaire est essayée en premier ; si elle échoue, la
source de repli unique (l'autre source, si configurée) est
essayée. Si la source primaire et le repli échouent — ou si
aucune source n'est configurée en mode ``AUTO`` — une erreur
critique est levée.
:return: Tuple ``(cours, événements scolaires)``.
:rtype: tuple[list[Lesson], list[SchoolEvent]]
:raises PipelineCriticalError: Si toutes les sources configurées échouent.
:raises PipelineCriticalError: Si toutes les sources tentées échouent.
"""
source = AgendaSource(self._settings.pronote.agenda_source)
if source is AgendaSource.ICAL:
try:
return self._fetch_agenda_ical()
except Exception as exc:
logger.error(
"Échec de la récupération iCal pour l'agenda : %s",
redact_exception(exc),
)
raise PipelineCriticalError(
"Impossible de récupérer l'agenda : la source iCal a échoué"
) from exc
if source is AgendaSource.PRONOTEPY:
try:
return self._fetch_agenda_pronotepy()
except Exception as exc:
logger.error(
"Échec de la récupération pronotepy pour l'agenda : %s",
redact_exception(exc),
)
raise PipelineCriticalError(
"Impossible de récupérer l'agenda : la source pronotepy a échoué"
) from exc
# Mode AUTO : essayer iCal d'abord, puis replier sur pronotepy.
primary, fallback = self._agenda_sources()
try:
return self._fetch_agenda_ical()
except Exception as exc:
logger.warning(
"Échec de la récupération iCal pour l'agenda : %s",
redact_exception(exc),
)
logger.info("Repli sur pronotepy pour l'agenda.")
try:
lessons, school_events = self._fetch_agenda_pronotepy()
return self._fetch_agenda_source(primary)
except Exception as exc:
logger.error(
"Échec de la récupération pronotepy pour l'agenda : %s",
"Échec de la récupération %s pour l'agenda : %s",
primary,
redact_exception(exc),
)
raise PipelineCriticalError(
"Impossible de récupérer l'agenda : les sources iCal et pronotepy ont échoué"
) from exc
if not lessons:
logger.warning(
"Le repli pronotepy pour l'agenda a retourné un résultat vide après l'échec "
"d'iCal : impossible de distinguer une absence de cours d'un échec silencieux."
)
return lessons, school_events
if fallback is None:
raise PipelineCriticalError(
f"Impossible de récupérer l'agenda : la source {primary} a échoué"
) from None
logger.info("Repli sur %s pour l'agenda.", fallback)
try:
lessons, school_events = self._fetch_agenda_source(fallback)
except Exception as exc:
logger.error(
"Échec de la récupération %s pour l'agenda : %s",
fallback,
redact_exception(exc),
)
raise PipelineCriticalError(
f"Impossible de récupérer l'agenda : les sources {primary}"
f" et {fallback} ont échoué"
) from None
if not lessons:
logger.warning(
"Le repli %s pour l'agenda a retourné un résultat vide après l'échec "
"de %s : impossible de distinguer une absence de cours d'un échec "
"silencieux.",
fallback,
primary,
)
return lessons, school_events
def _fetch_homework_ical(self, target_date: date) -> list[Homework]:
"""Récupère les devoirs depuis le flux iCal pour la date cible.
@@ -207,76 +267,112 @@ class PronoteFetcher:
lessons, _ = self._fetch_agenda_ical()
return collect_homeworks(lessons, target_date)
def _fetch_homework_pronotepy(self) -> list[Homework]:
"""Récupère les devoirs depuis pronotepy.
def _fetch_homework_pronotepy(self, target_date: date) -> list[Homework]:
"""Récupère les devoirs depuis pronotepy pour la date cible.
:return: Liste des devoirs.
:rtype: list[Homework]
"""
start, end = self._fetch_window()
_, homeworks = self._pronote_client.get_agenda_fallback(start, end)
return homeworks
def fetch_homework(self, target_date: date) -> list[Homework]:
"""Récupère les devoirs selon la source configurée.
En mode ``AUTO``, iCal est essayé en premier et pronotepy sert de
repli ; si les deux sources échouent, une erreur critique est levée.
Les devoirs sont filtrés sur la date d'échéance : seuls ceux dont
``due_on`` correspond à ``target_date`` sont conservés.
:param target_date: Date cible pour laquelle collecter les devoirs.
:return: Liste des devoirs.
:rtype: list[Homework]
:raises PipelineCriticalError: Si toutes les sources configurées échouent.
:raises pronotepy.PronoteAPIError: Si l'API Pronote échoue.
:raises ValueError: Si la configuration ou l'ENT est invalide.
:raises requests.RequestException: Si une requête réseau échoue.
:raises ConnectionError: Si la connexion réseau échoue.
:raises TimeoutError: Si la requête réseau expire.
"""
start, end = self._fetch_window()
homeworks = self._pronote_client.get_homeworks(start, end)
return [hw for hw in homeworks if hw.due_on == target_date]
def _homework_sources(self) -> tuple[_SourceName, _SourceName | None]:
"""Sélectionne la source primaire et le repli unique pour les devoirs.
En mode ``AUTO``, iCal est primaire si ``ical_url`` est configuré
(repli pronotepy si la configuration pronotepy est complète),
sinon pronotepy sans repli. Les modes explicites ``ICAL`` et
``PRONOTEPY`` désignent la source primaire et tentent l'autre
source en repli unique si elle est configurée.
:return: Tuple ``(source primaire, source de repli ou ``None``)``.
:rtype: tuple[_SourceName, _SourceName | None]
:raises PipelineCriticalError: Si aucune source n'est configurée en mode ``AUTO``.
"""
source = AgendaSource(self._settings.pronote.homework_source)
if source is AgendaSource.ICAL:
try:
return self._fetch_homework_ical(target_date)
except Exception as exc:
logger.error(
"Échec de la récupération iCal pour les devoirs : %s",
redact_exception(exc),
)
raise PipelineCriticalError(
"Impossible de récupérer les devoirs : la source iCal a échoué"
) from exc
return "ical", "pronotepy" if self._is_pronotepy_configured() else None
if source is AgendaSource.PRONOTEPY:
try:
return self._fetch_homework_pronotepy()
except Exception as exc:
logger.error(
"Échec de la récupération pronotepy pour les devoirs : %s",
redact_exception(exc),
)
raise PipelineCriticalError(
"Impossible de récupérer les devoirs : la source pronotepy a échoué"
) from exc
return "pronotepy", "ical" if self._is_ical_configured() else None
if self._is_ical_configured():
return "ical", "pronotepy" if self._is_pronotepy_configured() else None
if self._is_pronotepy_configured():
return "pronotepy", None
raise PipelineCriticalError(
"Impossible de récupérer les devoirs : ni la source iCal ni pronotepy n'est configurée"
) from None
# Mode AUTO : essayer iCal d'abord, puis replier sur pronotepy.
try:
def _fetch_homework_source(self, name: _SourceName, target_date: date) -> list[Homework]:
"""Récupère les devoirs depuis la source nommée.
:param name: Nom de la source (``"ical"`` ou ``"pronotepy"``).
:param target_date: Date cible pour laquelle collecter les devoirs.
:return: Liste des devoirs.
:rtype: list[Homework]
"""
if name == "ical":
return self._fetch_homework_ical(target_date)
except Exception as exc:
logger.warning(
"Échec de la récupération iCal pour les devoirs : %s",
redact_exception(exc),
)
logger.info("Repli sur pronotepy pour les devoirs.")
return self._fetch_homework_pronotepy(target_date)
def fetch_homework(self, target_date: date) -> list[Homework]:
"""Récupère les devoirs selon la source configurée.
La source primaire est essayée en premier ; si elle échoue, la
source de repli unique (l'autre source, si configurée) est
essayée. Si la source primaire et le repli échouent — ou si
aucune source n'est configurée en mode ``AUTO`` — une erreur
critique est levée.
:param target_date: Date cible pour laquelle collecter les devoirs.
:return: Liste des devoirs.
:rtype: list[Homework]
:raises PipelineCriticalError: Si toutes les sources tentées échouent.
"""
primary, fallback = self._homework_sources()
try:
homeworks = self._fetch_homework_pronotepy()
return self._fetch_homework_source(primary, target_date)
except Exception as exc:
logger.error(
"Échec de la récupération pronotepy pour les devoirs : %s",
"Échec de la récupération %s pour les devoirs : %s",
primary,
redact_exception(exc),
)
raise PipelineCriticalError(
"Impossible de récupérer les devoirs : les sources iCal et pronotepy ont échoué"
) from exc
if not homeworks:
logger.warning(
"Le repli pronotepy pour les devoirs a retourné un résultat vide après l'échec "
"d'iCal : impossible de distinguer une absence de devoirs d'un échec silencieux."
)
return homeworks
if fallback is None:
raise PipelineCriticalError(
f"Impossible de récupérer les devoirs : la source {primary} a échoué"
) from None
logger.info("Repli sur %s pour les devoirs.", fallback)
try:
homeworks = self._fetch_homework_source(fallback, target_date)
except Exception as exc:
logger.error(
"Échec de la récupération %s pour les devoirs : %s",
fallback,
redact_exception(exc),
)
raise PipelineCriticalError(
f"Impossible de récupérer les devoirs : les sources {primary}"
f" et {fallback} ont échoué"
) from None
if not homeworks:
logger.warning(
"Le repli %s pour les devoirs a retourné un résultat vide après "
"l'échec de %s : impossible de distinguer une absence de devoirs "
"d'un échec silencieux.",
fallback,
primary,
)
return homeworks
def fetch_messages(self) -> list[Message]:
"""Récupère les messages des discussions Pronote (toujours via pronotepy).
@@ -284,7 +380,14 @@ class PronoteFetcher:
:return: Liste des messages.
:rtype: list[Message]
"""
return self._pronote_client.get_messages()
try:
return self._pronote_client.get_messages()
except Exception as exc:
logger.error(
"Échec de la récupération des messages : %s",
redact_exception(exc),
)
raise
def fetch_informations(self) -> list[Message]:
"""Récupère les informations et sondages Pronote (toujours via pronotepy).
@@ -292,4 +395,11 @@ class PronoteFetcher:
:return: Liste des informations et sondages.
:rtype: list[Message]
"""
return self._pronote_client.get_informations()
try:
return self._pronote_client.get_informations()
except Exception as exc:
logger.error(
"Échec de la récupération des informations : %s",
redact_exception(exc),
)
raise