feat: orchestrer le pipeline M11

Co-authored-by: Codex/gpt-5.6-terra <codex-gpt-5-6-terra@agents.invalid>
This commit is contained in:
2026-09-08 11:28:02 +02:00
parent be5beb45aa
commit d7d31e14ff
14 changed files with 1154 additions and 20 deletions

24
TODO.md
View File

@@ -219,20 +219,20 @@ Construire et envoyer le message XMPP structuré via un compte bot dédié (mess
Composer et orchestrer toutes les étapes avec gestion d'erreurs dégradée et mode dry-run.
- [ ] Compléter si nécessaire la hiérarchie canonique dans `pronote_sync/errors.py` (`ErrorSeverity`, `PipelineError`, `PipelineWarning`, `PipelineCriticalError`) ; ne pas créer de doublon dans `pipeline/steps/errors.py`.
- [ ] Créer les étapes `pipeline/steps/` : `fetch.py`, `normalize.py`, `compare.py`, `caldav_sync.py`, `synthesis.py`, `send.py`, `fetch_blog.py`.
- [ ] Créer `pipeline/run.py` : `PipelineRunner` (composition root) orchestrant fetch → normalize → fetch_blog → compare → caldav_sync → synthesis → send.
- [ ] Gérer les erreurs dégradées (continuer sauf critique) et renvoyer `(PronoteData, erreurs + warns)`.
- [ ] Implémenter le mode `dry_run` (aucune écriture CalDAV/XMPP).
- [ ] Câbler l'injection des dépendances (Protocol + composition root), sans singleton global.
- [ ] Réutiliser, dans une même exécution, un unique téléchargement/parsing iCal pour l'agenda et les devoirs lorsque les sources sélectionnées le permettent ; rester sur un cache local au run, sans cache global ni persistant.
- [x] Compléter si nécessaire la hiérarchie canonique dans `pronote_sync/errors.py` (`ErrorSeverity`, `PipelineError`, `PipelineWarning`, `PipelineCriticalError`) ; ne pas créer de doublon dans `pipeline/steps/errors.py`.
- [x] Créer les étapes `pipeline/steps/` : `fetch.py`, `normalize.py`, `compare.py`, `caldav_sync.py`, `synthesis.py`, `send.py`, `fetch_blog.py`.
- [x] Créer `pipeline/run.py` : `PipelineRunner` (composition root) orchestrant fetch → normalize → fetch_blog → compare → caldav_sync → synthesis → send.
- [x] Gérer les erreurs dégradées (continuer sauf critique) et renvoyer `(PronoteData, erreurs + warns)`.
- [x] Implémenter le mode `dry_run` (aucune écriture CalDAV/XMPP).
- [x] Câbler l'injection des dépendances (Protocol + composition root), sans singleton global.
- [x] Réutiliser, dans une même exécution, un unique téléchargement/parsing iCal pour l'agenda et les devoirs lorsque les sources sélectionnées le permettent ; rester sur un cache local au run, sans cache global ni persistant.
### Critères d'acceptation
- Le pipeline complet s'exécute de bout en bout (mocks) dans le bon ordre.
- Une sélection iCal commune à l'agenda et aux devoirs ne déclenche qu'un téléchargement/parsing du flux par run.
- Une erreur non critique (ex : synthèse IA) n'empêche pas l'envoi XMPP.
- `dry_run=True` n'effectue aucune écriture ; aucune source disponible → erreur critique explicite.
- Si `THEORETICAL_AGENDA_PATH` est absent, le pipeline produit un diff vide sans erreur et n'instancie pas `AgendaComparator` ; si présent, il instancie le comparateur et effectue la comparaison.
- [x] Le pipeline complet s'exécute de bout en bout (mocks) dans le bon ordre.
- [x] Une sélection iCal commune à l'agenda et aux devoirs ne déclenche qu'un téléchargement/parsing du flux par run.
- [x] Une erreur non critique (ex : synthèse IA) n'empêche pas l'envoi XMPP.
- [x] `dry_run=True` n'effectue aucune écriture ; aucune source disponible → erreur critique explicite.
- [x] Si `THEORETICAL_AGENDA_PATH` est absent, le pipeline produit un diff vide sans erreur et n'instancie pas `AgendaComparator` ; si présent, il instancie le comparateur et effectue la comparaison.
---

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
from enum import StrEnum
class PronoteSyncError(Exception):
"""Erreur de base pour toutes les exceptions du projet pronote-sync.
@@ -16,24 +18,67 @@ class PronoteSyncError(Exception):
:param message: Message décrivant la cause de l'erreur.
"""
super().__init__(message)
self.message = message
class PipelineCriticalError(PronoteSyncError):
class ErrorSeverity(StrEnum):
"""Niveau de gravité d'une erreur produite par le pipeline."""
WARNING = "warning"
CRITICAL = "critical"
class PipelineError(PronoteSyncError):
"""Erreur structurée produite par une étape du pipeline.
:ivar severity: Niveau de gravité de l'erreur.
:ivar step: Étape ayant produit l'erreur, si elle est connue.
:ivar recoverable: Indique si le pipeline peut poursuivre son exécution.
"""
def __init__(
self,
message: str,
*,
severity: ErrorSeverity = ErrorSeverity.WARNING,
step: str | None = None,
recoverable: bool = True,
) -> None:
"""Initialise une erreur de pipeline.
:param message: Message descriptif expurgé.
:param severity: Niveau de gravité associé.
:param step: Étape ayant produit l'erreur.
:param recoverable: ``True`` si le pipeline peut continuer.
"""
super().__init__(message)
self.severity = severity
self.step = step
self.recoverable = recoverable
class PipelineCriticalError(PipelineError):
"""Erreur critique du pipeline, levée quand aucune récupération n'est possible.
Par exemple : échec simultané des sources iCal et pronotepy,
rendant impossible toute synchronisation.
"""
def __init__(self, message: str) -> None:
def __init__(self, message: str, step: str | None = None) -> None:
"""Initialise l'erreur critique avec un message descriptif.
:param message: Message décrivant la cause de l'erreur critique.
:param step: Étape ayant produit l'erreur critique.
"""
super().__init__(message)
super().__init__(
message,
severity=ErrorSeverity.CRITICAL,
step=step,
recoverable=False,
)
class PipelineWarning(PronoteSyncError):
class PipelineWarning(PipelineError):
"""Avertissement non bloquant pour une erreur récupérable du pipeline.
Contrairement à :class:`PipelineCriticalError`, cet avertissement signale
@@ -54,6 +99,9 @@ class PipelineWarning(PronoteSyncError):
:param message: Message décrivant la cause de l'avertissement.
:param step: Étape du pipeline ayant produit l'avertissement.
"""
super().__init__(message)
self.recoverable = True
self.step = step
super().__init__(
message,
severity=ErrorSeverity.WARNING,
step=step,
recoverable=True,
)

View File

@@ -0,0 +1,5 @@
"""Orchestration du pipeline Pronote → CalDAV → XMPP."""
from pronote_sync.pipeline.run import PipelineRunner
__all__ = ["PipelineRunner"]

View File

@@ -0,0 +1,281 @@
"""Composition root et orchestrateur du pipeline Pronote → CalDAV → XMPP."""
from __future__ import annotations
import logging
from collections.abc import Callable
from contextlib import AbstractContextManager, nullcontext
from datetime import datetime
from typing import Protocol, runtime_checkable
from pronote_sync.channels import get_channel
from pronote_sync.channels.protocol import Channel
from pronote_sync.config.settings import Settings
from pronote_sync.errors import PipelineCriticalError, PipelineError, PipelineWarning
from pronote_sync.models.blog import ExternalInfo
from pronote_sync.models.pronote import PronoteData
from pronote_sync.models.sync import CalDAVSyncResult, CalDAVSyncStatus
from pronote_sync.models.synthesis import SynthesisInput
from pronote_sync.models.xmpp import XmppMessage
from pronote_sync.pipeline.steps.caldav_sync import CalDAVSynchronizer, caldav_sync_step
from pronote_sync.pipeline.steps.compare import compare_step
from pronote_sync.pipeline.steps.fetch import fetch_step
from pronote_sync.pipeline.steps.fetch_blog import fetch_blog_step
from pronote_sync.pipeline.steps.normalize import normalize_step
from pronote_sync.pipeline.steps.send import send_step
from pronote_sync.pipeline.steps.synthesis import synthesis_step
from pronote_sync.sources.blog.rss import BlogRSSClient
from pronote_sync.sources.blog.state import BlogRSSState
from pronote_sync.sources.pronote.client import PronoteClient
from pronote_sync.sources.pronote.fallback import PronoteFetcher, PronoteFetcherProtocol
from pronote_sync.sources.theoretical import get_theoretical_provider
from pronote_sync.sync.diff import AgendaComparator
from pronote_sync.sync.synchronizer import synchronize
from pronote_sync.synthesis import get_synthesis_provider
from pronote_sync.synthesis.provider import SynthesisProvider
from pronote_sync.utils.redaction import redact_exception, redact_secrets
logger = logging.getLogger(__name__)
def _synchronize_caldav(data: PronoteData, settings: Settings) -> CalDAVSyncResult:
"""Adapte le synchroniseur CalDAV de production au protocole injecté.
:param data: Données Pronote normalisées à synchroniser.
:param settings: Configuration effective de l'exécution.
:return: Résultat de la synchronisation CalDAV.
:rtype: CalDAVSyncResult
"""
return synchronize(data, settings)
@runtime_checkable
class _RunContextFetcher(PronoteFetcherProtocol, Protocol):
"""Protocole interne d'un fetcher capable d'isoler un cache par run."""
def run_context(self) -> AbstractContextManager[None]:
"""Retourne le contexte de durée de vie d'une exécution.
:return: Contexte éphémère associé à l'exécution.
:rtype: AbstractContextManager[None]
"""
...
class PipelineRunner:
"""Orchestre les étapes fetch → normalize → blog → compare → CalDAV → IA → XMPP.
Toutes les dépendances sont injectables. La méthode :meth:`from_settings`
constitue la composition root de production et ne crée aucun singleton.
"""
def __init__(
self,
*,
settings: Settings,
pronote_fetcher: PronoteFetcherProtocol,
caldav_synchronizer: CalDAVSynchronizer = _synchronize_caldav,
agenda_comparator: AgendaComparator | None = None,
synthesis_provider: SynthesisProvider | None = None,
channel: Channel | None = None,
blog_client: BlogRSSClient | None = None,
blog_state: BlogRSSState | None = None,
dry_run: bool | None = None,
now_provider: Callable[[], datetime] = datetime.now,
) -> None:
"""Initialise un pipeline entièrement injectable.
:param settings: Configuration de base du pipeline.
:param pronote_fetcher: Source Pronote à utiliser.
:param caldav_synchronizer: Service CalDAV injecté.
:param agenda_comparator: Comparateur théorique, absent si désactivé.
:param synthesis_provider: Fournisseur IA optionnel.
:param channel: Canal XMPP optionnel.
:param blog_client: Client RSS optionnel.
:param blog_state: État RSS associé au client optionnel.
:param dry_run: Surcharge optionnelle du mode dry-run de la configuration.
:param now_provider: Horloge injectée pour rendre l'exécution testable.
"""
self._settings = settings
self._pronote_fetcher = pronote_fetcher
self._caldav_synchronizer = caldav_synchronizer
self._agenda_comparator = agenda_comparator
self._synthesis_provider = synthesis_provider
self._channel = channel
self._blog_client = blog_client
self._blog_state = blog_state
self._dry_run = settings.app.dry_run if dry_run is None else dry_run
self._now_provider = now_provider
self._errors: list[PipelineError] = []
self._warnings: list[PipelineWarning] = []
@classmethod
def from_settings(cls, settings: Settings, *, dry_run: bool | None = None) -> PipelineRunner:
"""Construit les dépendances de production sans singleton global.
:param settings: Configuration validée de l'application.
:param dry_run: Surcharge optionnelle du mode dry-run.
:return: Pipeline prêt à être exécuté.
:rtype: PipelineRunner
"""
effective_dry_run = settings.app.dry_run if dry_run is None else dry_run
theoretical_provider = get_theoretical_provider(
settings.app.theoretical_agenda_path,
settings.app.school_holidays_path,
settings.app.theoretical_week_anchor_date,
settings.app.theoretical_week_anchor_type,
)
comparator = (
AgendaComparator(theoretical_provider) if theoretical_provider is not None else None
)
blog_client = BlogRSSClient(settings.blog.rss_url) if settings.blog.enabled else None
blog_state = BlogRSSState() if settings.blog.enabled else None
return cls(
settings=settings,
pronote_fetcher=PronoteFetcher(settings, PronoteClient(settings.pronote)),
agenda_comparator=comparator,
synthesis_provider=get_synthesis_provider(settings.ai),
channel=get_channel(settings.xmpp, dry_run=effective_dry_run),
blog_client=blog_client,
blog_state=blog_state,
dry_run=effective_dry_run,
)
def _effective_settings(self) -> Settings:
"""Retourne la configuration dont le dry-run reflète l'exécution courante.
:return: Copie de configuration à passer aux dépendances.
:rtype: Settings
"""
if self._settings.app.dry_run == self._dry_run:
return self._settings
return self._settings.model_copy(
update={"app": self._settings.app.model_copy(update={"dry_run": self._dry_run})}
)
def _run_context(self) -> AbstractContextManager[None]:
"""Retourne le contexte isolant les éventuels caches de source.
:return: Contexte de durée de vie du run, vide pour un fetcher générique.
:rtype: AbstractContextManager[None]
"""
if isinstance(self._pronote_fetcher, _RunContextFetcher):
return self._pronote_fetcher.run_context()
return nullcontext()
def _warn(self, step: str, message: str) -> None:
"""Enregistre et journalise un avertissement expurgé.
:param step: Étape ayant échoué.
:param message: Message déjà expurgé.
"""
warning = PipelineWarning(message, step=step)
self._warnings.append(warning)
logger.warning("Étape %s dégradée : %s", step, warning.message)
def run(self) -> tuple[PronoteData | None, list[PipelineError]]:
"""Exécute le pipeline complet dans l'ordre contractuel.
Une erreur de récupération critique interrompt l'exécution. Les erreurs
des étapes facultatives sont converties en :class:`PipelineWarning` afin
que les étapes suivantes, notamment XMPP, restent exécutées.
:return: Données Pronote normalisées ou ``None``, puis erreurs et avertissements.
:rtype: tuple[PronoteData | None, list[PipelineError]]
"""
self._errors = []
self._warnings = []
now = self._now_provider()
effective_settings = self._effective_settings()
try:
with self._run_context():
fetched, fetch_warnings = fetch_step(self._pronote_fetcher, today=now.date())
self._warnings.extend(fetch_warnings)
data = normalize_step(fetched, generated_at=now)
try:
blog_articles = fetch_blog_step(self._blog_client, self._blog_state)
except Exception as exc:
self._warn("fetch_blog", redact_exception(exc))
blog_articles = []
try:
agenda_diff = compare_step(self._agenda_comparator, data)
except Exception as exc:
self._warn("compare", redact_exception(exc))
from pronote_sync.models.diff import AgendaDiff
agenda_diff = AgendaDiff(target_date=data.target_date)
try:
sync_result = caldav_sync_step(
self._caldav_synchronizer, data, effective_settings
)
if sync_result.status is CalDAVSyncStatus.FAILED:
caldav_errors = redact_secrets(
"; ".join(sync_result.errors),
extra_secrets=(effective_settings.caldav.password,)
if effective_settings.caldav.password is not None
else (),
)
self._warn("caldav_sync", caldav_errors or "Échec CalDAV")
except Exception as exc:
self._warn("caldav_sync", redact_exception(exc))
try:
synthesis = synthesis_step(
self._synthesis_provider,
SynthesisInput(
agenda_diff=agenda_diff,
messages=data.messages,
school_events=data.school_events,
target_date=data.target_date,
),
)
except Exception as exc:
self._warn("synthesis", redact_exception(exc))
synthesis = None
message = XmppMessage(
target_date=data.target_date,
synthesis=synthesis.text if synthesis is not None else None,
homeworks=tuple(data.homeworks),
changes=agenda_diff.changes,
messages=tuple(data.messages),
external_info=ExternalInfo(blog_articles=tuple(blog_articles))
if blog_articles
else None,
)
if self._channel is not None and not self._dry_run:
try:
if not send_step(self._channel, message):
self._warn("send", "Le canal XMPP a refusé l'envoi")
except Exception as exc:
self._warn("send", redact_exception(exc))
return data, [*self._errors, *self._warnings]
except PipelineCriticalError as exc:
logger.error("Erreur critique du pipeline : %s", exc.message)
self._errors.append(exc)
except Exception as exc:
error = PipelineCriticalError(
f"Erreur inattendue du pipeline : {redact_exception(exc)}", step="pipeline"
)
logger.error("Erreur critique du pipeline : %s", error.message)
self._errors.append(error)
return None, [*self._errors, *self._warnings]
def get_errors(self) -> list[PipelineError]:
"""Retourne les erreurs critiques de la dernière exécution.
:return: Copie des erreurs critiques.
:rtype: list[PipelineError]
"""
return list(self._errors)
def get_warnings(self) -> list[PipelineWarning]:
"""Retourne les avertissements de la dernière exécution.
:return: Copie des avertissements non bloquants.
:rtype: list[PipelineWarning]
"""
return list(self._warnings)

View File

@@ -0,0 +1,19 @@
"""Étapes isolées utilisées par l'orchestrateur du pipeline."""
from pronote_sync.pipeline.steps.caldav_sync import caldav_sync_step
from pronote_sync.pipeline.steps.compare import compare_step
from pronote_sync.pipeline.steps.fetch import fetch_step
from pronote_sync.pipeline.steps.fetch_blog import fetch_blog_step
from pronote_sync.pipeline.steps.normalize import normalize_step
from pronote_sync.pipeline.steps.send import send_step
from pronote_sync.pipeline.steps.synthesis import synthesis_step
__all__ = [
"caldav_sync_step",
"compare_step",
"fetch_blog_step",
"fetch_step",
"normalize_step",
"send_step",
"synthesis_step",
]

View File

@@ -0,0 +1,37 @@
"""Étape d'appel à la synchronisation CalDAV."""
from __future__ import annotations
from typing import Protocol
from pronote_sync.config.settings import Settings
from pronote_sync.models.pronote import PronoteData
from pronote_sync.models.sync import CalDAVSyncResult
class CalDAVSynchronizer(Protocol):
"""Protocole injectable de synchronisation CalDAV."""
def __call__(self, data: PronoteData, settings: Settings) -> CalDAVSyncResult:
"""Synchronise les données Pronote vers CalDAV.
:param data: Données Pronote normalisées.
:param settings: Configuration effective de l'exécution.
:return: Résultat de la synchronisation.
:rtype: CalDAVSyncResult
"""
...
def caldav_sync_step(
synchronizer: CalDAVSynchronizer, data: PronoteData, settings: Settings
) -> CalDAVSyncResult:
"""Exécute la synchronisation CalDAV injectée.
:param synchronizer: Service de synchronisation injecté.
:param data: Données Pronote normalisées.
:param settings: Configuration effective de l'exécution.
:return: Résultat CalDAV.
:rtype: CalDAVSyncResult
"""
return synchronizer(data, settings)

View File

@@ -0,0 +1,20 @@
"""Étape de comparaison de l'agenda réel avec l'agenda théorique."""
from __future__ import annotations
from pronote_sync.models.diff import AgendaDiff
from pronote_sync.models.pronote import PronoteData
from pronote_sync.sync.diff import AgendaComparator
def compare_step(comparator: AgendaComparator | None, data: PronoteData) -> AgendaDiff:
"""Compare l'agenda ou retourne un diff vide si la comparaison est désactivée.
:param comparator: Comparateur configuré, ou ``None`` sans agenda théorique.
:param data: Données Pronote normalisées.
:return: Diff d'agenda pour la date cible.
:rtype: AgendaDiff
"""
if comparator is None:
return AgendaDiff(target_date=data.target_date)
return comparator.compare(data.lessons, data.target_date)

View File

@@ -0,0 +1,126 @@
"""Étape de récupération des données Pronote pour une exécution du pipeline."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from pronote_sync.errors import PipelineCriticalError, PipelineWarning
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.sources.pronote.fallback import PronoteFetcherProtocol
from pronote_sync.utils.redaction import redact_exception
@dataclass(frozen=True)
class FetchedPronoteData:
"""Représente les données brutes récupérées pendant une exécution.
:ivar lessons: Cours récupérés depuis la source sélectionnée.
:ivar homeworks: Devoirs destinés à la date cible.
:ivar school_events: Événements scolaires récupérés avec l'agenda.
:ivar messages: Messages et informations Pronote disponibles.
:ivar target_date: Date cible du digest.
"""
lessons: list[Lesson]
homeworks: list[Homework]
school_events: list[SchoolEvent]
messages: list[Message]
target_date: date
def resolve_target_date(
today: date, lessons: list[Lesson], school_events: list[SchoolEvent]
) -> date:
"""Détermine la date cible du digest à partir de l'agenda disponible.
La règle privilégie J+1 lorsqu'il contient des cours. Si la journée en
cours contient des cours mais pas J+1, le prochain cours connu est choisi.
Sans cours correspondant, J+1 est conservé, y compris pendant les vacances.
:param today: Date de référence de l'exécution.
:param lessons: Cours récupérés pour la fenêtre de synchronisation.
:param school_events: Événements scolaires récupérés (réservés aux évolutions
du libellé de jour sans cours).
:return: Date cible du digest.
:rtype: date
"""
del school_events
tomorrow = date.fromordinal(today.toordinal() + 1)
lesson_dates = {lesson.start.date() for lesson in lessons}
if tomorrow in lesson_dates:
return tomorrow
if today in lesson_dates:
future_dates = sorted(day for day in lesson_dates if day > today)
if future_dates:
return future_dates[0]
return tomorrow
def _fetch_optional_messages(
fetcher: PronoteFetcherProtocol,
) -> tuple[list[Message], list[PipelineWarning]]:
"""Récupère les messages et informations sans bloquer le pipeline.
:param fetcher: Fetcher Pronote configuré.
:return: Messages disponibles et avertissements éventuels.
:rtype: tuple[list[Message], list[PipelineWarning]]
"""
messages: list[Message] = []
warnings: list[PipelineWarning] = []
for step, method in (
("fetch_messages", fetcher.fetch_messages),
("fetch_informations", fetcher.fetch_informations),
):
try:
messages.extend(method())
except Exception as exc:
warnings.append(
PipelineWarning(
f"Récupération non critique échouée : {redact_exception(exc)}",
step=step,
)
)
return messages, warnings
def fetch_step(
fetcher: PronoteFetcherProtocol, *, today: date | None = None
) -> tuple[FetchedPronoteData, list[PipelineWarning]]:
"""Récupère les données Pronote critiques et les compléments dégradables.
L'agenda et les devoirs sont critiques : leur échec empêche de produire un
digest fiable et est donc propagé comme :class:`PipelineCriticalError`.
Les messages et informations sont facultatifs ; leur échec produit un
avertissement et une liste partielle reste valide.
:param fetcher: Fetcher Pronote configuré.
:param today: Date de référence, injectée par les tests ; J courant par défaut.
:return: Données récupérées et avertissements non critiques.
:rtype: tuple[FetchedPronoteData, list[PipelineWarning]]
:raises PipelineCriticalError: Si l'agenda ou les devoirs ne sont pas disponibles.
"""
try:
lessons, school_events = fetcher.fetch_agenda()
target_date = resolve_target_date(today or date.today(), lessons, school_events)
homeworks = fetcher.fetch_homework(target_date)
except PipelineCriticalError:
raise
except Exception as exc:
raise PipelineCriticalError(
f"Récupération Pronote impossible : {redact_exception(exc)}", step="fetch"
) from None
messages, warnings = _fetch_optional_messages(fetcher)
return (
FetchedPronoteData(
lessons=lessons,
homeworks=homeworks,
school_events=school_events,
messages=messages,
target_date=target_date,
),
warnings,
)

View File

@@ -0,0 +1,32 @@
"""Étape de récupération non bloquante des articles RSS du collège."""
from __future__ import annotations
from pronote_sync.models.blog import BlogArticle
from pronote_sync.sources.blog.rss import BlogRSSClient
from pronote_sync.sources.blog.state import BlogRSSState
from pronote_sync.utils.redaction import redact_exception
def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) -> list[BlogArticle]:
"""Récupère les articles RSS nouveaux en conservant l'état du client.
:param client: Client RSS configuré, ou ``None`` lorsque le blog est désactivé.
:param state: État de déduplication et de cache HTTP associé au run.
:return: Nouveaux articles du blog.
:rtype: list[BlogArticle]
:raises RuntimeError: Si la récupération RSS injectée échoue.
"""
if client is None or state is None:
return []
try:
etag, last_modified = state.get_cache_headers()
result = client.fetch_and_parse(
known_guids=state.get_known_guids(), etag=etag, last_modified=last_modified
)
if not result.not_modified:
state.add_guids(article.id for article in result.articles)
state.update_cache_headers(result.etag, result.last_modified)
return list(result.articles)
except Exception as exc:
raise RuntimeError(f"Récupération du blog échouée : {redact_exception(exc)}") from None

View File

@@ -0,0 +1,31 @@
"""Étape de normalisation et d'ordonnancement déterministe des données Pronote."""
from __future__ import annotations
from datetime import datetime
from pronote_sync.models.pronote import PronoteData
from pronote_sync.pipeline.steps.fetch import FetchedPronoteData
def normalize_step(fetched: FetchedPronoteData, *, generated_at: datetime) -> PronoteData:
"""Construit le contrat ``PronoteData`` dans un ordre déterministe.
:param fetched: Données brutes produites par :func:`fetch_step`.
:param generated_at: Horodatage de l'exécution fourni par l'orchestrateur.
:return: Données Pronote normalisées.
:rtype: PronoteData
"""
return PronoteData(
lessons=sorted(fetched.lessons, key=lambda lesson: (lesson.start, lesson.id)),
homeworks=sorted(
fetched.homeworks, key=lambda homework: (homework.due_on, homework.subject, homework.id)
),
school_events=sorted(
fetched.school_events,
key=lambda event: (event.from_date, event.to_date, event.kind.value, event.label),
),
messages=sorted(fetched.messages, key=lambda message: (message.date, message.id)),
target_date=fetched.target_date,
generated_at=generated_at,
)

View File

@@ -0,0 +1,17 @@
"""Étape d'envoi du digest sur le canal de notification."""
from __future__ import annotations
from pronote_sync.channels.protocol import Channel
from pronote_sync.models.xmpp import XmppMessage
def send_step(channel: Channel, message: XmppMessage) -> bool:
"""Envoie le digest et retourne le statut fourni par le canal.
:param channel: Canal de sortie configuré.
:param message: Digest XMPP à transmettre.
:return: ``True`` si l'envoi a réussi, ``False`` sinon.
:rtype: bool
"""
return channel.send(message)

View File

@@ -0,0 +1,21 @@
"""Étape de génération optionnelle de synthèse IA."""
from __future__ import annotations
from pronote_sync.models.synthesis import SynthesisInput, SynthesisResult
from pronote_sync.synthesis.provider import SynthesisProvider
def synthesis_step(
provider: SynthesisProvider | None, input_data: SynthesisInput
) -> SynthesisResult | None:
"""Génère une synthèse lorsque le fournisseur IA est activé.
:param provider: Fournisseur IA optionnel.
:param input_data: Données à synthétiser.
:return: Synthèse produite, ou ``None`` si le fournisseur est désactivé.
:rtype: SynthesisResult | None
"""
if provider is None:
return None
return provider.generate(input_data)

View File

@@ -16,6 +16,8 @@ d'origine ne sont jamais chaînées (``from None``).
from __future__ import annotations
import logging
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import date, timedelta
from enum import StrEnum
from typing import Literal, Protocol
@@ -100,6 +102,30 @@ class PronoteFetcher:
"""
self._settings: Settings = settings
self._pronote_client: PronoteClientProtocol = pronote_client
self._run_ical_agenda: tuple[list[Lesson], list[SchoolEvent]] | None = None
self._cache_ical_for_run = False
@contextmanager
def run_context(self) -> Iterator[None]:
"""Active un cache iCal éphémère pour une exécution du pipeline.
Le cache couvre à la fois le téléchargement et le parsing du flux.
Il est toujours supprimé à la sortie du contexte, y compris si une
étape échoue : il ne peut donc pas devenir un cache global ou
persistant entre deux exécutions.
:yield: Aucun objet.
:rtype: Iterator[None]
"""
previous_cache = self._run_ical_agenda
previous_enabled = self._cache_ical_for_run
self._run_ical_agenda = None
self._cache_ical_for_run = True
try:
yield
finally:
self._run_ical_agenda = previous_cache
self._cache_ical_for_run = previous_enabled
def _fetch_window(self) -> tuple[date, date]:
"""Calcule la fenêtre de synchronisation autour de la date du jour.
@@ -144,12 +170,17 @@ class PronoteFetcher:
:raises OSError: Si le fichier iCal local est illisible.
:raises requests.RequestException: Si la récupération HTTP échoue.
"""
if self._cache_ical_for_run and self._run_ical_agenda is not None:
return self._run_ical_agenda
ical_url = self._settings.pronote.ical_url
if ical_url is None:
raise ValueError("PRONOTE_ICAL_URL est requis pour la source iCal")
raw_ical = fetch_ical(ical_url.get_secret_value())
lessons, _, school_events = parse_ical(raw_ical)
return lessons, school_events
result = (lessons, school_events)
if self._cache_ical_for_run:
self._run_ical_agenda = result
return result
def _fetch_agenda_pronotepy(self) -> tuple[list[Lesson], list[SchoolEvent]]:
"""Récupère l'agenda depuis pronotepy.

View File

@@ -0,0 +1,466 @@
"""Integration tests for the M11 pipeline orchestration."""
from __future__ import annotations
from datetime import date, datetime
from typing import Any, cast
import pytest
from pydantic import SecretStr
from pronote_sync.config.settings import AppSettings, PronoteSettings, Settings
from pronote_sync.errors import PipelineCriticalError, PipelineWarning
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent
from pronote_sync.models.diff import AgendaDiff
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message
from pronote_sync.models.sync import CalDAVSyncResult, CalDAVSyncStatus
from pronote_sync.pipeline.run import PipelineRunner
from pronote_sync.sources.pronote.fallback import PronoteFetcher
from pronote_sync.sync.diff import AgendaComparator
class StubFetcher:
"""Pronote fetcher returning deterministic data and recording its calls."""
def __init__(self, calls: list[str], lesson: Lesson, homework: Homework) -> None:
"""Store the dependencies required by the stub.
:param calls: Shared call-order recorder.
:param lesson: Lesson returned by the agenda method.
:param homework: Homework returned by the homework method.
"""
self._calls = calls
self._lesson = lesson
self._homework = homework
def fetch_agenda(self) -> tuple[list[Lesson], list[SchoolEvent]]:
"""Return one lesson for the target date.
:return: The lesson and no school event.
:rtype: tuple[list[Lesson], list[SchoolEvent]]
"""
self._calls.append("fetch")
return [self._lesson], []
def fetch_homework(self, target_date: date) -> list[Homework]:
"""Return the configured homework.
:param target_date: Requested due date.
:return: The configured homework.
:rtype: list[Homework]
"""
self._calls.append("fetch_homework")
assert target_date == self._lesson.start.date()
return [self._homework]
def fetch_messages(self) -> list[Message]:
"""Return no Pronote messages.
:return: An empty list.
:rtype: list[Message]
"""
self._calls.append("fetch_messages")
return []
def fetch_informations(self) -> list[Message]:
"""Return no Pronote information messages.
:return: An empty list.
:rtype: list[Message]
"""
self._calls.append("fetch_informations")
return []
class StubChannel:
"""Notification channel recording its send attempts."""
def __init__(self, calls: list[str]) -> None:
"""Store the shared call-order recorder.
:param calls: Shared call-order recorder.
"""
self._calls = calls
self.messages: list[Any] = []
def send(self, message: Any) -> bool:
"""Record an outgoing message.
:param message: Message produced by the runner.
:return: Always ``True``.
:rtype: bool
"""
self.messages.append(message)
self._calls.append("send")
return True
class StubComparator:
"""Agenda comparator recording comparison calls."""
def __init__(self, calls: list[str]) -> None:
"""Store the shared call-order recorder.
:param calls: Shared call-order recorder.
"""
self._calls = calls
def compare(self, lessons: list[Lesson], target_date: date) -> AgendaDiff:
"""Return an empty diff after recording the comparison.
:param lessons: Normalized lessons.
:param target_date: Target digest date.
:return: Empty agenda diff.
:rtype: AgendaDiff
"""
self._calls.append("compare")
return AgendaDiff(target_date=target_date)
@pytest.fixture
def pipeline_inputs() -> tuple[Lesson, Homework]:
"""Return deterministic Pronote data targeting 2026-09-09.
:return: A lesson and homework pair.
:rtype: tuple[Lesson, Homework]
"""
lesson = Lesson(
id="lesson-1",
start=datetime(2026, 9, 9, 8, 0),
end=datetime(2026, 9, 9, 9, 0),
subject="Maths",
group=None,
status=LessonStatus.NORMAL,
content=None,
)
homework = Homework(
id="homework-1",
subject="Maths",
assigned_on=None,
due_on=date(2026, 9, 9),
text="Exercise 1",
)
return lesson, homework
def successful_sync_result() -> CalDAVSyncResult:
"""Construit un résultat CalDAV de succès compatible avec mypy.
:return: Résultat de synchronisation sans changement.
:rtype: CalDAVSyncResult
"""
return CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=0, updated=0, removed=0)
def _record_ical_fetch(calls: list[str], url: str) -> str:
"""Enregistre un téléchargement iCal simulé.
:param calls: Liste partagée des téléchargements.
:param url: URL iCal reçue par la source.
:return: Contenu iCal minimal simulé.
:rtype: str
"""
calls.append(url)
return "BEGIN:VCALENDAR"
def test_runner_executes_steps_in_contractual_order(
pipeline_inputs: tuple[Lesson, Homework],
) -> None:
"""The runner executes fetch, blog, comparison, CalDAV, synthesis, then XMPP."""
lesson, homework = pipeline_inputs
calls: list[str] = []
def synchronize(data: Any, settings: Settings) -> CalDAVSyncResult:
"""Record the CalDAV step.
:param data: Normalized Pronote data.
:param settings: Effective settings.
:return: Successful result.
:rtype: CalDAVSyncResult
"""
del data, settings
calls.append("caldav_sync")
return successful_sync_result()
class RaisingSynthesisProvider:
"""Synthesis provider used solely to record the optional stage."""
def generate(self, input_data: Any) -> None:
"""Record synthesis and return no result.
:param input_data: Synthesis input.
:return: No synthesis.
:rtype: None
"""
del input_data
calls.append("synthesis")
return None
runner = PipelineRunner(
settings=Settings(),
pronote_fetcher=StubFetcher(calls, lesson, homework),
caldav_synchronizer=synchronize,
agenda_comparator=cast("AgendaComparator", StubComparator(calls)),
synthesis_provider=RaisingSynthesisProvider(),
channel=StubChannel(calls),
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is not None
assert errors == []
assert calls == [
"fetch",
"fetch_homework",
"fetch_messages",
"fetch_informations",
"compare",
"caldav_sync",
"synthesis",
"send",
]
def test_runner_continues_to_xmpp_when_synthesis_fails(
pipeline_inputs: tuple[Lesson, Homework],
) -> None:
"""A non-critical synthesis exception produces a warning and still sends XMPP."""
lesson, homework = pipeline_inputs
calls: list[str] = []
class FailingSynthesisProvider:
"""Synthesis provider raising a non-critical error."""
def generate(self, input_data: Any) -> None:
"""Raise a deterministic optional-stage failure.
:param input_data: Synthesis input.
:raises RuntimeError: Always.
"""
del input_data
raise RuntimeError("synthetic AI failure")
runner = PipelineRunner(
settings=Settings(),
pronote_fetcher=StubFetcher(calls, lesson, homework),
caldav_synchronizer=lambda data, settings: successful_sync_result(),
synthesis_provider=FailingSynthesisProvider(),
channel=StubChannel(calls),
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is not None
assert calls[-1] == "send"
assert len(errors) == 1
assert isinstance(errors[0], PipelineWarning)
assert errors[0].step == "synthesis"
def test_runner_dry_run_skips_caldav_and_xmpp_writes(
pipeline_inputs: tuple[Lesson, Homework],
) -> None:
"""Dry-run bypasses both mutable destination boundaries."""
lesson, homework = pipeline_inputs
calls: list[str] = []
def dry_run_caldav(data: Any, settings: Settings) -> CalDAVSyncResult:
"""Verify that the CalDAV boundary receives dry-run settings.
:param data: Normalized Pronote data.
:param settings: Effective settings.
:return: A skipped result.
:rtype: CalDAVSyncResult
"""
del data
calls.append("caldav_sync")
assert settings.app.dry_run is True
return CalDAVSyncResult(status=CalDAVSyncStatus.SKIPPED, added=0, updated=0, removed=0)
runner = PipelineRunner(
settings=Settings(app=AppSettings(dry_run=True)),
pronote_fetcher=StubFetcher(calls, lesson, homework),
caldav_synchronizer=dry_run_caldav,
channel=StubChannel(calls),
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is not None
assert errors == []
assert "caldav_sync" in calls
assert "send" not in calls
def test_runner_without_theoretical_agenda_produces_an_empty_diff(
pipeline_inputs: tuple[Lesson, Homework],
) -> None:
"""A disabled theoretical agenda reaches XMPP with no agenda changes."""
lesson, homework = pipeline_inputs
calls: list[str] = []
channel = StubChannel(calls)
runner = PipelineRunner(
settings=Settings(app=AppSettings(theoretical_agenda_path=None)),
pronote_fetcher=StubFetcher(calls, lesson, homework),
caldav_synchronizer=lambda data, settings: successful_sync_result(),
channel=channel,
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is not None
assert errors == []
assert len(channel.messages) == 1
assert channel.messages[0].changes == ()
def test_runner_reports_critical_error_when_no_pronote_source_is_available() -> None:
"""No configured Pronote source returns an explicit critical pipeline error."""
settings = Settings(pronote=PronoteSettings())
runner = PipelineRunner(
settings=settings,
pronote_fetcher=PronoteFetcher(settings, object()), # type: ignore[arg-type]
)
data, errors = runner.run()
assert data is None
assert len(errors) == 1
assert isinstance(errors[0], PipelineCriticalError)
assert errors[0].step is None
assert "ni la source iCal ni pronotepy" in errors[0].message
def test_from_settings_without_theoretical_agenda_does_not_instantiate_comparator(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Missing theoretical configuration keeps comparison disabled without constructing it."""
import pronote_sync.pipeline.run as run_module
def forbidden_comparator(provider: object) -> None:
"""Fail if comparison is constructed without configuration.
:param provider: The provider unexpectedly supplied.
:raises AssertionError: Always.
"""
del provider
raise AssertionError("AgendaComparator must not be instantiated")
monkeypatch.setattr(run_module, "AgendaComparator", forbidden_comparator)
runner = PipelineRunner.from_settings(Settings(app=AppSettings(theoretical_agenda_path=None)))
assert runner._agenda_comparator is None
def test_from_settings_with_theoretical_agenda_instantiates_comparator(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Configured theoretical agenda constructs the comparator with its provider."""
import pronote_sync.pipeline.run as run_module
provider = object()
constructed_with: list[object] = []
class RecordingComparator:
"""Comparator constructor recording the supplied provider."""
def __init__(self, received_provider: object) -> None:
"""Record the provider used by the composition root.
:param received_provider: The constructed theoretical provider.
"""
constructed_with.append(received_provider)
monkeypatch.setattr(run_module, "get_theoretical_provider", lambda *args: provider)
monkeypatch.setattr(run_module, "AgendaComparator", RecordingComparator)
runner = PipelineRunner.from_settings(
Settings(app=AppSettings(theoretical_agenda_path="/agenda.json"))
)
assert constructed_with == [provider]
assert isinstance(runner._agenda_comparator, RecordingComparator)
def test_runner_reuses_ical_download_and_parse_within_one_run(
monkeypatch: pytest.MonkeyPatch,
pipeline_inputs: tuple[Lesson, Homework],
) -> None:
"""An iCal agenda/homework selection downloads and parses once per runner run."""
import pronote_sync.sources.pronote.fallback as fallback_module
lesson, _ = pipeline_inputs
settings = Settings(
pronote=PronoteSettings(
ical_url=SecretStr("https://pronote.example.test/calendar.ics"),
agenda_source="ical",
homework_source="ical",
)
)
fetch_calls: list[str] = []
monkeypatch.setattr(
fallback_module,
"fetch_ical",
lambda url: _record_ical_fetch(fetch_calls, url),
)
monkeypatch.setattr(fallback_module, "parse_ical", lambda raw: ([lesson], [], []))
class NoMessageClient:
"""Minimal pronotepy client for non-critical M11 fetches."""
def get_messages(self) -> list[Message]:
"""Return no messages.
:return: An empty list.
:rtype: list[Message]
"""
return []
def get_informations(self) -> list[Message]:
"""Return no information messages.
:return: An empty list.
:rtype: list[Message]
"""
return []
def get_lessons(self, start: date, end: date) -> list[Lesson]:
"""Ne retourne aucun cours de repli.
:param start: Début de la fenêtre.
:param end: Fin de la fenêtre.
:return: Liste vide.
:rtype: list[Lesson]
"""
del start, end
return []
def get_homeworks(self, start: date, end: date) -> list[Homework]:
"""Ne retourne aucun devoir de repli.
:param start: Début de la fenêtre.
:param end: Fin de la fenêtre.
:return: Liste vide.
:rtype: list[Homework]
"""
del start, end
return []
runner = PipelineRunner(
settings=settings,
pronote_fetcher=PronoteFetcher(settings, NoMessageClient()),
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is not None
assert errors == []
assert fetch_calls == ["https://pronote.example.test/calendar.ics"]