diff --git a/TODO.md b/TODO.md index 9bbc36c..4e07213 100644 --- a/TODO.md +++ b/TODO.md @@ -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. --- diff --git a/pronote_sync/errors.py b/pronote_sync/errors.py index 58d0369..ee9e166 100644 --- a/pronote_sync/errors.py +++ b/pronote_sync/errors.py @@ -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, + ) diff --git a/pronote_sync/pipeline/__init__.py b/pronote_sync/pipeline/__init__.py index e69de29..d012fa9 100644 --- a/pronote_sync/pipeline/__init__.py +++ b/pronote_sync/pipeline/__init__.py @@ -0,0 +1,5 @@ +"""Orchestration du pipeline Pronote → CalDAV → XMPP.""" + +from pronote_sync.pipeline.run import PipelineRunner + +__all__ = ["PipelineRunner"] diff --git a/pronote_sync/pipeline/run.py b/pronote_sync/pipeline/run.py new file mode 100644 index 0000000..f6bfcb0 --- /dev/null +++ b/pronote_sync/pipeline/run.py @@ -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) diff --git a/pronote_sync/pipeline/steps/__init__.py b/pronote_sync/pipeline/steps/__init__.py index e69de29..a704144 100644 --- a/pronote_sync/pipeline/steps/__init__.py +++ b/pronote_sync/pipeline/steps/__init__.py @@ -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", +] diff --git a/pronote_sync/pipeline/steps/caldav_sync.py b/pronote_sync/pipeline/steps/caldav_sync.py new file mode 100644 index 0000000..3475166 --- /dev/null +++ b/pronote_sync/pipeline/steps/caldav_sync.py @@ -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) diff --git a/pronote_sync/pipeline/steps/compare.py b/pronote_sync/pipeline/steps/compare.py new file mode 100644 index 0000000..781377b --- /dev/null +++ b/pronote_sync/pipeline/steps/compare.py @@ -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) diff --git a/pronote_sync/pipeline/steps/fetch.py b/pronote_sync/pipeline/steps/fetch.py new file mode 100644 index 0000000..842bd52 --- /dev/null +++ b/pronote_sync/pipeline/steps/fetch.py @@ -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, + ) diff --git a/pronote_sync/pipeline/steps/fetch_blog.py b/pronote_sync/pipeline/steps/fetch_blog.py new file mode 100644 index 0000000..16c888b --- /dev/null +++ b/pronote_sync/pipeline/steps/fetch_blog.py @@ -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 diff --git a/pronote_sync/pipeline/steps/normalize.py b/pronote_sync/pipeline/steps/normalize.py new file mode 100644 index 0000000..b84056c --- /dev/null +++ b/pronote_sync/pipeline/steps/normalize.py @@ -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, + ) diff --git a/pronote_sync/pipeline/steps/send.py b/pronote_sync/pipeline/steps/send.py new file mode 100644 index 0000000..e5ddea5 --- /dev/null +++ b/pronote_sync/pipeline/steps/send.py @@ -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) diff --git a/pronote_sync/pipeline/steps/synthesis.py b/pronote_sync/pipeline/steps/synthesis.py new file mode 100644 index 0000000..d55be48 --- /dev/null +++ b/pronote_sync/pipeline/steps/synthesis.py @@ -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) diff --git a/pronote_sync/sources/pronote/fallback.py b/pronote_sync/sources/pronote/fallback.py index a3e441f..9974c5d 100644 --- a/pronote_sync/sources/pronote/fallback.py +++ b/pronote_sync/sources/pronote/fallback.py @@ -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. diff --git a/tests/integration/test_pipeline_runner.py b/tests/integration/test_pipeline_runner.py new file mode 100644 index 0000000..297132b --- /dev/null +++ b/tests/integration/test_pipeline_runner.py @@ -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"]