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:
24
TODO.md
24
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.
|
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`.
|
- [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`.
|
||||||
- [ ] 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 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.
|
- [x] 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)`.
|
- [x] 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).
|
- [x] Implémenter le mode `dry_run` (aucune écriture CalDAV/XMPP).
|
||||||
- [ ] Câbler l'injection des dépendances (Protocol + composition root), sans singleton global.
|
- [x] 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] 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
|
### Critères d'acceptation
|
||||||
- Le pipeline complet s'exécute de bout en bout (mocks) dans le bon ordre.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] `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] 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
class PronoteSyncError(Exception):
|
class PronoteSyncError(Exception):
|
||||||
"""Erreur de base pour toutes les exceptions du projet pronote-sync.
|
"""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.
|
:param message: Message décrivant la cause de l'erreur.
|
||||||
"""
|
"""
|
||||||
super().__init__(message)
|
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.
|
"""Erreur critique du pipeline, levée quand aucune récupération n'est possible.
|
||||||
|
|
||||||
Par exemple : échec simultané des sources iCal et pronotepy,
|
Par exemple : échec simultané des sources iCal et pronotepy,
|
||||||
rendant impossible toute synchronisation.
|
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.
|
"""Initialise l'erreur critique avec un message descriptif.
|
||||||
|
|
||||||
:param message: Message décrivant la cause de l'erreur critique.
|
: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.
|
"""Avertissement non bloquant pour une erreur récupérable du pipeline.
|
||||||
|
|
||||||
Contrairement à :class:`PipelineCriticalError`, cet avertissement signale
|
Contrairement à :class:`PipelineCriticalError`, cet avertissement signale
|
||||||
@@ -54,6 +99,9 @@ class PipelineWarning(PronoteSyncError):
|
|||||||
:param message: Message décrivant la cause de l'avertissement.
|
:param message: Message décrivant la cause de l'avertissement.
|
||||||
:param step: Étape du pipeline ayant produit l'avertissement.
|
:param step: Étape du pipeline ayant produit l'avertissement.
|
||||||
"""
|
"""
|
||||||
super().__init__(message)
|
super().__init__(
|
||||||
self.recoverable = True
|
message,
|
||||||
self.step = step
|
severity=ErrorSeverity.WARNING,
|
||||||
|
step=step,
|
||||||
|
recoverable=True,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Orchestration du pipeline Pronote → CalDAV → XMPP."""
|
||||||
|
|
||||||
|
from pronote_sync.pipeline.run import PipelineRunner
|
||||||
|
|
||||||
|
__all__ = ["PipelineRunner"]
|
||||||
|
|||||||
281
pronote_sync/pipeline/run.py
Normal file
281
pronote_sync/pipeline/run.py
Normal 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)
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
37
pronote_sync/pipeline/steps/caldav_sync.py
Normal file
37
pronote_sync/pipeline/steps/caldav_sync.py
Normal 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)
|
||||||
20
pronote_sync/pipeline/steps/compare.py
Normal file
20
pronote_sync/pipeline/steps/compare.py
Normal 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)
|
||||||
126
pronote_sync/pipeline/steps/fetch.py
Normal file
126
pronote_sync/pipeline/steps/fetch.py
Normal 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,
|
||||||
|
)
|
||||||
32
pronote_sync/pipeline/steps/fetch_blog.py
Normal file
32
pronote_sync/pipeline/steps/fetch_blog.py
Normal 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
|
||||||
31
pronote_sync/pipeline/steps/normalize.py
Normal file
31
pronote_sync/pipeline/steps/normalize.py
Normal 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,
|
||||||
|
)
|
||||||
17
pronote_sync/pipeline/steps/send.py
Normal file
17
pronote_sync/pipeline/steps/send.py
Normal 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)
|
||||||
21
pronote_sync/pipeline/steps/synthesis.py
Normal file
21
pronote_sync/pipeline/steps/synthesis.py
Normal 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)
|
||||||
@@ -16,6 +16,8 @@ d'origine ne sont jamais chaînées (``from None``).
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from typing import Literal, Protocol
|
from typing import Literal, Protocol
|
||||||
@@ -100,6 +102,30 @@ class PronoteFetcher:
|
|||||||
"""
|
"""
|
||||||
self._settings: Settings = settings
|
self._settings: Settings = settings
|
||||||
self._pronote_client: PronoteClientProtocol = pronote_client
|
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]:
|
def _fetch_window(self) -> tuple[date, date]:
|
||||||
"""Calcule la fenêtre de synchronisation autour de la date du jour.
|
"""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 OSError: Si le fichier iCal local est illisible.
|
||||||
:raises requests.RequestException: Si la récupération HTTP échoue.
|
: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
|
ical_url = self._settings.pronote.ical_url
|
||||||
if ical_url is None:
|
if ical_url is None:
|
||||||
raise ValueError("PRONOTE_ICAL_URL est requis pour la source iCal")
|
raise ValueError("PRONOTE_ICAL_URL est requis pour la source iCal")
|
||||||
raw_ical = fetch_ical(ical_url.get_secret_value())
|
raw_ical = fetch_ical(ical_url.get_secret_value())
|
||||||
lessons, _, school_events = parse_ical(raw_ical)
|
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]]:
|
def _fetch_agenda_pronotepy(self) -> tuple[list[Lesson], list[SchoolEvent]]:
|
||||||
"""Récupère l'agenda depuis pronotepy.
|
"""Récupère l'agenda depuis pronotepy.
|
||||||
|
|||||||
466
tests/integration/test_pipeline_runner.py
Normal file
466
tests/integration/test_pipeline_runner.py
Normal 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"]
|
||||||
Reference in New Issue
Block a user