Ajoute le mode d'authentification PRONOTE_AUTH_MODE=qr_token comme alternative au mode password pour les instances Pronote utilisant HubEduConnect/EduConnect où l'authentification par mot de passe échoue (CAPTCHA, MFA, flux SAML). Nouveaux éléments : - PronoteSettings : auth_mode, qr_code_file, qr_pin (SecretStr) - PronoteAuthState : persistance du token rotatif dans .pronote_auth_state.json (écriture atomique, permissions 0600, symlink-safe via O_EXCL|O_NOFOLLOW) - PronoteClient._connect_qr_token() : token_login avec creds persistés, qrcode_login pour l'enrôlement initial, export_credentials persisté après chaque login réussi - PronoteAuthRotationError : levée en cas d'échec de rotation du token, propagée sans wrapping à travers PronoteFetcher et fetch_step jusqu'à PipelineRunner.run() qui notifie via XMPP (si canal disponible et dry_run inactif) - _is_pronotepy_configured() mode-aware : qr_token ne requiert que PRONOTE_URL - _collect_auth_secrets() : redaction des secrets explicites (token, PIN, jeton QR) dans tous les logs du chemin d'authentification Documentation : - .env.example : PRONOTE_AUTH_MODE, PRONOTE_QR_CODE_FILE, PRONOTE_QR_PIN - AGENTS.md : contrat d'authentification QR code / token - Wiki GuidePronote : section enrôlement, exécutions suivantes, ré-enrôlement Tests (686 passés, couverture 94.87%) : - 5 tests config QR, 9 tests auth_state, 10 tests client QR, 3 tests propagation, 4 tests intégration rotation end-to-end, 4 tests fallback mode-aware - Tests de non-fuite : sentinelles distinctes pour token, PIN, jeton QR Co-authored-by: coder/litellm/coder <coder@agents.invalid>
341 lines
15 KiB
Python
341 lines
15 KiB
Python
"""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,
|
|
PronoteAuthRotationError,
|
|
)
|
|
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.auth_state import PronoteAuthState
|
|
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._redaction_secrets = settings.redaction_secrets()
|
|
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,
|
|
auth_state=(
|
|
PronoteAuthState() if settings.pronote.auth_mode == "qr_token" else None
|
|
),
|
|
),
|
|
),
|
|
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 _redact(self, exc: Exception) -> str:
|
|
"""Rédige une exception avec les secrets configurés.
|
|
|
|
:param exc: Exception dont le message doit être masqué.
|
|
:return: Message d'erreur avec secrets configurés remplacés par ``REDACTED``.
|
|
:rtype: str
|
|
"""
|
|
return redact_exception(exc, self._redaction_secrets)
|
|
|
|
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.
|
|
|
|
Une :class:`PronoteAuthRotationError` interrompt également l'exécution :
|
|
l'erreur est journalisée expurgée, une notification XMPP actionnable est
|
|
envoyée (sauf en dry-run ou sans canal), puis un résultat dégradé est
|
|
retourné.
|
|
|
|
: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 PipelineCriticalError:
|
|
raise
|
|
except Exception as exc:
|
|
self._warn("fetch_blog", self._redact(exc))
|
|
blog_articles = []
|
|
|
|
try:
|
|
agenda_diff = compare_step(self._agenda_comparator, data)
|
|
except PipelineCriticalError:
|
|
raise
|
|
except Exception as exc:
|
|
self._warn("compare", self._redact(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=self._redaction_secrets,
|
|
)
|
|
self._warn("caldav_sync", caldav_errors or "Échec CalDAV")
|
|
except PipelineCriticalError:
|
|
raise
|
|
except Exception as exc:
|
|
self._warn("caldav_sync", self._redact(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 PipelineCriticalError:
|
|
raise
|
|
except Exception as exc:
|
|
self._warn("synthesis", self._redact(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 PipelineCriticalError:
|
|
raise
|
|
except Exception as exc:
|
|
self._warn("send", self._redact(exc))
|
|
return data, [*self._errors, *self._warnings]
|
|
except PronoteAuthRotationError as exc:
|
|
error = PipelineCriticalError(self._redact(exc), step="pronote")
|
|
logger.error("Erreur critique du pipeline : %s", error.message)
|
|
if self._channel is not None and not self._dry_run:
|
|
message = XmppMessage(
|
|
target_date=now.date(),
|
|
synthesis=(
|
|
"⚠️ Rotation du token Pronote échouée. Le token d'authentification est "
|
|
"expiré ou invalide. Action requise : supprimez le fichier "
|
|
".pronote_auth_state.json et relancez le pipeline avec un nouveau QR "
|
|
"code (PRONOTE_QR_CODE_FILE + PRONOTE_QR_PIN)."
|
|
),
|
|
external_info=None,
|
|
)
|
|
try:
|
|
if not send_step(self._channel, message):
|
|
self._warn("send", "Le canal XMPP a refusé l'envoi")
|
|
except Exception as send_exc:
|
|
# L'envoi de la notification est un dernier avertissement : son échec
|
|
# ne doit pas masquer l'erreur de rotation, déjà critique.
|
|
self._warn("send", self._redact(send_exc))
|
|
self._errors.append(error)
|
|
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 : {self._redact(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)
|