Compare commits

..

5 Commits

Author SHA1 Message Date
fd9b604849 feat(M12): CLI entry point with dry-run, log-level, redacted error display
Implement the CLI entry point for pronote-sync:

cli/main.py:
- main() entry point with --dry-run (tri-state: None defers to settings,
  True overrides) and --log-level (choices: DEBUG/INFO/WARNING/ERROR/CRITICAL)
- setup_logging called before settings load (to capture config errors),
  then reconfigured with settings.app.log_level
- PipelineRunner.from_settings() as composition root, runner.run()
- Return codes: 0 success, 1 failure, 2 argparse rejection
- _safe_traceback: strips exception messages, replaces with "erreur expurgée",
  walks __cause__/__context__ with cycle protection
- _settings_secrets: collects redaction_secrets() + usernames + JID/recipient
- All error messages redacted via redact_secrets() with configured secrets
- DEBUG-level traceback only shown when DEBUG is enabled

cli/__init__.py:
- Module docstring added (French, Sphinx/reST)

tests/e2e/test_cli.py (8 tests):
- Dry-run and log-level propagation to composition root
- Configured dry-run preserved (tri-state None)
- Success with warnings returns 0
- Pipeline error redaction at DEBUG (sentinel secret)
- Configuration failure redacted traceback at DEBUG
- Pronote username non-disclosure
- Unexpected pipeline exception: redacted traceback at DEBUG, no traceback at INFO
- Argparse rejection of unknown log level (exit code 2)

Coverage: cli/ 94.74%, 627 total tests pass.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-08 15:57:28 +02:00
1019b22808 docs(M11): align GUIDE sections 4.2.1 and 11.3 with FIXME_M11 corrections
GUIDE_DEV_PYTHON.md:
- §11.3: add except PipelineCriticalError: raise before each non-critical
  except in the illustrative PipelineRunner.run() code
- §11.3: replace redact_exception(exc) with self._redact(exc) in all except
  blocks, add explanatory paragraph about _redaction_secrets and _redact()
- §11.3: fix Google-style Returns: to Sphinx/reST :return: and :rtype:
- §11.3: fix malformed Markdown code fence (get_errors/get_warnings orphaned)
- §4.2.1: fix redact_exception() example to pass extra_secrets to
  redact_secrets() in the return statement

TODO.md M11:
- Add and check criterion: PipelineCriticalError from non-blocking step
  stops the pipeline

.secrets.baseline:
- Line numbers updated for documentation shifts

Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
2026-09-08 12:47:42 +02:00
28c695795a fix(M11): propagate PipelineCriticalError, redact configured secrets, signal blog failures
Correct 4 findings from the independent M11 review:

#1 (Critical) — PipelineCriticalError was downgraded to PipelineWarning:
  - Add except PipelineCriticalError: raise before each except Exception
    in all 5 non-blocking steps (fetch_blog, compare, caldav_sync, synthesis, send)
  - Critical errors now propagate to the outer handler and stop the pipeline

#2 (Critical) — redact_exception() did not use configured secrets:
  - Extend redact_exception() with extra_secrets parameter (upward compatible)
  - Harden redact_secrets(): sort extra_secrets by length descending
  - Add Settings.redaction_secrets() collecting all 6 SecretStr fields
  - Add PipelineRunner._redact(exc) using self._redaction_secrets
  - All except blocks in run() now use self._redact(exc)
  - CalDAV FAILED-status path uses full redaction_secrets collection

#3 (Medium) — BlogRSSClient silently swallowed failures:
  - Add error field to BlogRSSFetchResult
  - rss.py sets error on failure paths (except Exception, bozo/invalid feed)
  - fetch_blog_step raises RuntimeError when result.error is set
  - PipelineRunner now produces PipelineWarning for blog failures

#4 (Medium) — Test coverage at 80%, now 91%:
  - 11 new integration tests covering blog failure/success, compare failure,
    CalDAV failure (exception + FAILED status), send False/exception,
    PipelineCriticalError propagation, secret redaction with sentinel,
    empty agenda/homework, iCal cache cleanup
  - Secret redaction test uses mock (no network) and proves configured-secret
    propagation via non-URL sentinel in RuntimeError

Validation: 619 tests pass, ruff/mypy/bandit/pre-commit green, coverage 91%.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-08 12:20:29 +02:00
26b083561a Ignore worktres 2026-09-08 11:50:25 +02:00
d7d31e14ff feat: orchestrer le pipeline M11
Co-authored-by: Codex/gpt-5.6-terra <codex-gpt-5-6-terra@agents.invalid>
2026-09-08 11:28:02 +02:00
25 changed files with 2286 additions and 113 deletions

1
.gitignore vendored
View File

@@ -53,6 +53,7 @@ Thumbs.db
# --- Local scratch / WIP files --- # --- Local scratch / WIP files ---
FIXME_* FIXME_*
TEST_* TEST_*
.worktress/
# --- Logs --- # --- Logs ---
*.log *.log

View File

@@ -140,7 +140,7 @@
"filename": "GUIDE_DEV_PYTHON.md", "filename": "GUIDE_DEV_PYTHON.md",
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
"is_verified": true, "is_verified": true,
"line_number": 5029, "line_number": 5064,
"is_secret": false "is_secret": false
} }
], ],
@@ -177,5 +177,5 @@
} }
] ]
}, },
"generated_at": "2026-09-08T00:29:02Z" "generated_at": "2026-09-08T10:45:46Z"
} }

View File

@@ -492,9 +492,12 @@ le contexte et le traceback complet.
> `redact_exception` est implémenté comme une **fonction au niveau du module** dans `utils/redaction.py`, et non comme une méthode de `RedactingFormatter` (contrairement à §4.2.2 où elle apparaît comme une méthode). > `redact_exception` est implémenté comme une **fonction au niveau du module** dans `utils/redaction.py`, et non comme une méthode de `RedactingFormatter` (contrairement à §4.2.2 où elle apparaît comme une méthode).
> `redact_url` utilise `urlsplit`/`urlunsplit`/`parse_qsl` au lieu de `urlparse`/`urlunparse`/`parse_qs`. > `redact_url` utilise `urlsplit`/`urlunsplit`/`parse_qsl` au lieu de `urlparse`/`urlunparse`/`parse_qs`.
> La correspondance des clés sensibles est insensible à la casse. > La correspondance des clés sensibles est insensible à la casse.
> `redact_secrets()` trie les `extra_secrets` par longueur décroissante pour éviter les masquages partiels.
> `Settings.redaction_secrets()` retourne un tuple des secrets configurés (mots de passe Pronote, CalDAV, XMPP et clé API IA) à passer à `redact_exception`.
```python ```python
import re import re
from typing import Iterable, SecretStr
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
@@ -543,6 +546,20 @@ def redact_secrets(text: str) -> str:
) )
return text return text
def redact_exception(
exc: Exception, extra_secrets: Iterable[SecretStr | str] = ()
) -> str:
"""
Masque les secrets dans une exception.
:param exc: Exception à masquer.
:param extra_secrets: Secrets configurés à masquer dans le message.
:return: Message de l'exception avec les secrets masqués.
:rtype: str
"""
return redact_secrets(str(exc), extra_secrets)
``` ```
#### 4.2.2 Configuration des logs (`logging.py`) #### 4.2.2 Configuration des logs (`logging.py`)
@@ -4576,6 +4593,7 @@ class PipelineRunner:
blog_rss_client: Optional["BlogRSSClient"] = None, blog_rss_client: Optional["BlogRSSClient"] = None,
blog_state: Optional["## (section obsolète supprimée)"] = None, blog_state: Optional["## (section obsolète supprimée)"] = None,
dry_run: bool = False, dry_run: bool = False,
settings: "Settings" | None = None,
): ):
self.pronote_fetcher = pronote_fetcher self.pronote_fetcher = pronote_fetcher
self.caldav_client = caldav_client self.caldav_client = caldav_client
@@ -4587,13 +4605,19 @@ class PipelineRunner:
self.dry_run = dry_run self.dry_run = dry_run
self._errors: List[PipelineError] = [] self._errors: List[PipelineError] = []
self._warnings: List[PipelineWarning] = [] self._warnings: List[PipelineWarning] = []
self._redaction_secrets = settings.redaction_secrets() if settings else ()
def _redact(self, exc: Exception) -> str:
"""Masque les secrets configurés dans une exception."""
from ..utils.redaction import redact_exception
return redact_exception(exc, self._redaction_secrets)
def run(self) -> Tuple[Optional[PronoteData], List[PipelineError]]: def run(self) -> Tuple[Optional[PronoteData], List[PipelineError]]:
""" """
Exécute le pipeline complet. Exécute le pipeline complet.
Returns: :return: Tuple (PronoteData final, liste des erreurs).
Tuple (PronoteData final, liste des erreurs). :rtype: tuple[PronoteData | None, list[PipelineError]]
""" """
pronote_data: Optional[PronoteData] = None pronote_data: Optional[PronoteData] = None
agenda_diff = None agenda_diff = None
@@ -4622,73 +4646,81 @@ class PipelineRunner:
logger.warning(f"Étape 'normalize' échouée: {e.message}") logger.warning(f"Étape 'normalize' échouée: {e.message}")
return None, self._errors + self._warnings return None, self._errors + self._warnings
# Étape 2 bis: Récupération du blog (RSS) # Étape 2 bis: Récupération du blog (RSS)
if self.blog_rss_client and self.blog_state: if self.blog_rss_client and self.blog_state:
try: try:
blog_articles = fetch_blog_step( blog_articles = fetch_blog_step(
self.blog_rss_client, self.blog_rss_client,
self.blog_state, self.blog_state,
enabled=True, enabled=True,
) )
except PipelineError as e: except PipelineCriticalError:
self._warnings.append(PipelineWarning( raise
message=f"Récupération du blog échouée: {e.message}", except PipelineError as e:
step="fetch_blog", self._warnings.append(PipelineWarning(
)) message=f"Récupération du blog échouée: {e.message}",
logger.warning(f"Étape 'fetch_blog' échouée (non bloquante): {e.message}") step="fetch_blog",
blog_articles = [] ))
logger.warning(f"Étape 'fetch_blog' échouée (non bloquante): {e.message}")
blog_articles = []
# Étape 3: Comparaison avec l'agenda théorique # Étape 3: Comparaison avec l'agenda théorique
try: try:
agenda_diff = compare_step( agenda_diff = compare_step(
self.agenda_comparator, self.agenda_comparator,
pronote_data.lessons, pronote_data.lessons,
pronote_data.target_date, pronote_data.target_date,
) )
except PipelineError as e: except PipelineCriticalError:
self._warnings.append(PipelineWarning( raise
message=f"Comparaison échouée: {e.message}", except PipelineError as e:
step="compare", self._warnings.append(PipelineWarning(
)) message=f"Comparaison échouée: {e.message}",
logger.warning(f"Étape 'compare' échouée (non bloquante): {e.message}") step="compare",
))
logger.warning(f"Étape 'compare' échouée (non bloquante): {e.message}")
# Étape 4: Synchronisation CalDAV # Étape 4: Synchronisation CalDAV
try: try:
sync_result = caldav_sync_step( sync_result = caldav_sync_step(
self.caldav_client, self.caldav_client,
pronote_data.lessons, pronote_data.lessons,
pronote_data.homeworks, pronote_data.homeworks,
pronote_data.school_events, pronote_data.school_events,
) )
if sync_result and sync_result.status.value == "failed": if sync_result and sync_result.status.value == "failed":
self._warnings.append(PipelineWarning( self._warnings.append(PipelineWarning(
message=f"Synchronisation CalDAV échouée: {sync_result.errors}", message=f"Synchronisation CalDAV échouée: {sync_result.errors}",
step="sync", step="sync",
)) ))
logger.warning("Synchronisation CalDAV échouée (non bloquante)") logger.warning("Synchronisation CalDAV échouée (non bloquante)")
except PipelineError as e: except PipelineCriticalError:
self._warnings.append(PipelineWarning( raise
message=f"Synchronisation CalDAV échouée: {e.message}", except PipelineError as e:
step="sync", self._warnings.append(PipelineWarning(
)) message=f"Synchronisation CalDAV échouée: {e.message}",
logger.warning(f"Étape 'sync' échouée (non bloquante): {e.message}") step="sync",
))
logger.warning(f"Étape 'sync' échouée (non bloquante): {e.message}")
# Étape 5: Synthèse IA (optionnelle) # Étape 5: Synthèse IA (optionnelle)
if self.synthesis_provider and agenda_diff: if self.synthesis_provider and agenda_diff:
try: try:
synthesis_input = SynthesisInput( synthesis_input = SynthesisInput(
agenda_diff=agenda_diff, agenda_diff=agenda_diff,
messages=pronote_data.messages, messages=pronote_data.messages,
school_events=pronote_data.school_events, school_events=pronote_data.school_events,
target_date=pronote_data.target_date, target_date=pronote_data.target_date,
) )
synthesis_result = synthesis_step(self.synthesis_provider, synthesis_input) synthesis_result = synthesis_step(self.synthesis_provider, synthesis_input)
except PipelineError as e: except PipelineCriticalError:
self._warnings.append(PipelineWarning( raise
message=f"Synthèse IA échouée: {e.message}", except PipelineError as e:
step="synthesis", self._warnings.append(PipelineWarning(
)) message=f"Synthèse IA échouée: {e.message}",
logger.warning(f"Étape 'synthesis' échouée (non bloquante): {e.message}") step="synthesis",
))
logger.warning(f"Étape 'synthesis' échouée (non bloquante): {e.message}")
# Étape 6: Construction du message XMPP # Étape 6: Construction du message XMPP
xmpp_message = XmppMessage( xmpp_message = XmppMessage(
@@ -4702,29 +4734,30 @@ class PipelineRunner:
) if blog_articles else None, ) if blog_articles else None,
) )
# Étape 7: Envoi XMPP # Étape 7: Envoi XMPP
try: try:
send_step(self.channel, xmpp_message) send_step(self.channel, xmpp_message)
except PipelineError as e: except PipelineCriticalError:
self._warnings.append(PipelineWarning( raise
message=f"Envoi XMPP échoué: {e.message}", except PipelineError as e:
step="send", self._warnings.append(PipelineWarning(
)) message=f"Envoi XMPP échoué: {e.message}",
logger.warning(f"Étape 'send' échouée (non bloquante): {e.message}") step="send",
))
logger.warning(f"Étape 'send' échouée (non bloquante): {e.message}")
return pronote_data, self._errors + self._warnings return pronote_data, self._errors + self._warnings
except PipelineCriticalError as e: except PipelineCriticalError as e:
logger.error(f"Erreur critique dans le pipeline: {e.message}") logger.error(f"Erreur critique dans le pipeline: {e.message}")
return None, [e] return None, [e]
except Exception as e: except Exception as e:
from ..utils.redaction import redact_secrets safe_error = self._redact(e)
safe_error = redact_secrets(str(e)) logger.error(f"Erreur inattendue dans le pipeline: {safe_error}")
logger.error(f"Erreur inattendue dans le pipeline: {safe_error}") return None, [PipelineCriticalError(
return None, [PipelineCriticalError( message=safe_error,
message=safe_error, step="unknown",
step="unknown", )]
)]
def get_errors(self) -> List[PipelineError]: def get_errors(self) -> List[PipelineError]:
"""Récupère la liste des erreurs.""" """Récupère la liste des erreurs."""
@@ -4735,6 +4768,8 @@ class PipelineRunner:
return self._warnings return self._warnings
``` ```
Le ``PipelineRunner`` calcule ``self._redaction_secrets = settings.redaction_secrets()`` dans son constructeur. La méthode privée ``_redact(exc)`` délègue à ``redact_exception(exc, self._redaction_secrets)`` pour masquer les secrets configurés (mots de passe Pronote, CalDAV, XMPP et clé API IA). Chaque bloc ``except Exception`` utilise ``self._redact(exc)`` au lieu de ``redact_exception(exc)`` directement.
### 11.4 Étapes du pipeline (`pipeline/steps/`) ### 11.4 Étapes du pipeline (`pipeline/steps/`)

33
TODO.md
View File

@@ -219,20 +219,21 @@ 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.
- [x] Les erreurs critiques (`PipelineCriticalError`) propagées depuis une étape non-bloquante arrêtent le pipeline.
--- ---
@@ -240,10 +241,10 @@ Composer et orchestrer toutes les étapes avec gestion d'erreurs dégradée et m
Exposer le lancement du pipeline via une interface en ligne de commande. Exposer le lancement du pipeline via une interface en ligne de commande.
- [ ] Créer `cli/main.py` : `main()` (point d'entrée `pronote-sync`), args `--dry-run`, `--log-level`. - [x] Créer `cli/main.py` : `main()` (point d'entrée `pronote-sync`), args `--dry-run`, `--log-level`.
- [ ] Initialiser les logs (`setup_logging`) et charger `settings` au démarrage. - [x] Initialiser les logs (`setup_logging`) et charger `settings` au démarrage.
- [ ] Construire la composition root et lancer `PipelineRunner.run()`. - [x] Construire la composition root et lancer `PipelineRunner.run()`.
- [ ] Gérer le code de retour et l'affichage des erreurs (redactées). - [x] Gérer le code de retour et l'affichage des erreurs (redactées).
### Critères d'acceptation ### Critères d'acceptation
- `pronote-sync --dry-run --log-level DEBUG` s'exécute sans effet de bord. - `pronote-sync --dry-run --log-level DEBUG` s'exécute sans effet de bord.

View File

@@ -0,0 +1 @@
"""Interface en ligne de commande du pipeline ``pronote-sync``."""

153
pronote_sync/cli/main.py Normal file
View File

@@ -0,0 +1,153 @@
"""Point d'entrée en ligne de commande du pipeline Pronote → CalDAV → XMPP."""
from __future__ import annotations
import argparse
import logging
import traceback
from collections.abc import Sequence
from pydantic import SecretStr
from pronote_sync.config.env import load_settings
from pronote_sync.config.settings import Settings
from pronote_sync.pipeline.run import PipelineRunner
from pronote_sync.utils.logging import setup_logging
from pronote_sync.utils.redaction import redact_secrets
logger = logging.getLogger(__name__)
_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
def _parse_arguments(arguments: Sequence[str] | None = None) -> argparse.Namespace:
"""Analyse les options de lancement du programme.
:param arguments: Arguments à analyser, ou ``None`` pour ceux du processus.
:return: Options de ligne de commande validées.
:rtype: argparse.Namespace
"""
parser = argparse.ArgumentParser(description="Synchronise Pronote vers CalDAV et XMPP.")
parser.add_argument(
"--dry-run",
action="store_true",
default=None,
help="Simule la synchronisation sans écrire vers CalDAV ni XMPP.",
)
parser.add_argument(
"--log-level",
choices=_LOG_LEVELS,
type=str.upper,
help="Niveau de verbosité des journaux.",
)
return parser.parse_args(arguments)
def _settings_secrets(settings: Settings) -> tuple[SecretStr | str, ...]:
"""Retourne les valeurs sensibles connues pour la rédaction des messages.
Centraliser ces valeurs garantit que les diagnostics CLI ne divulguent pas
les secrets configurés, y compris lorsque le niveau ``DEBUG`` est demandé.
:param settings: Configuration validée de l'application.
:return: Secrets connus à transmettre au mécanisme de rédaction.
:rtype: tuple[SecretStr | str, ...]
"""
candidates = (
*settings.redaction_secrets(),
settings.pronote.username,
settings.caldav.username,
settings.xmpp.jid,
settings.xmpp.to,
)
return tuple(dict.fromkeys(secret for secret in candidates if secret is not None))
def _safe_traceback(
exception: BaseException, *, extra_secrets: Sequence[SecretStr | str] = ()
) -> str:
"""Construit une pile complète sans inclure les messages d'exception bruts.
Les noms de fichiers, lignes et fonctions conservent la valeur de diagnostic
de la pile. Les messages et les chaînes de causes sont volontairement
remplacés, car ils peuvent provenir d'une bibliothèque externe.
:param exception: Exception à représenter sans divulguer son contenu.
:param extra_secrets: Valeurs sensibles configurées à rédiger dans les cadres.
:return: Représentation de la pile et de ses causes, expurgée.
:rtype: str
"""
lines = ["Traceback (most recent call last):"]
current: BaseException | None = exception
seen: set[int] = set()
while current is not None and id(current) not in seen:
seen.add(id(current))
for frame in traceback.extract_tb(current.__traceback__):
lines.append(f' File "{frame.filename}", line {frame.lineno}, in {frame.name}')
lines.append(f"{type(current).__name__}: erreur expurgée")
next_exception = current.__cause__ or current.__context__
if next_exception is not None and id(next_exception) not in seen:
lines.append("La cause ou le contexte précédent est le suivant :")
current = next_exception
return redact_secrets("\n".join(lines), extra_secrets=extra_secrets)
def _log_failure(
message: str,
exception: BaseException,
*,
extra_secrets: Sequence[SecretStr | str] = (),
) -> None:
"""Journalise une erreur et sa pile expurgée uniquement en niveau DEBUG.
:param message: Message public déjà sûr à afficher hors DEBUG.
:param exception: Exception dont la pile doit être présentée de façon sûre.
:param extra_secrets: Valeurs sensibles configurées à rédiger.
:rtype: None
"""
logger.error("%s", redact_secrets(message, extra_secrets=extra_secrets))
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s", _safe_traceback(exception, extra_secrets=extra_secrets))
def main(arguments: Sequence[str] | None = None) -> int:
"""Lance le pipeline configuré et retourne son code de sortie.
En niveau ``DEBUG``, les piles sont affichées sans leurs messages externes
bruts afin de préserver le diagnostic sans exposer de secret.
:param arguments: Arguments optionnels, principalement utiles aux appels programmatiques.
:return: ``0`` en cas de succès, ``1`` sinon (après analyse des arguments).
:rtype: int
:raises SystemExit: Si argparse rejette les arguments (code de sortie 2).
"""
parsed_arguments = _parse_arguments(arguments)
setup_logging(parsed_arguments.log_level or "INFO")
try:
settings = load_settings()
except Exception as exception:
_log_failure("Configuration invalide ou indisponible.", exception)
return 1
setup_logging(parsed_arguments.log_level or settings.app.log_level)
try:
runner = PipelineRunner.from_settings(settings, dry_run=parsed_arguments.dry_run)
data, errors = runner.run()
except Exception as exception:
_log_failure(
"Échec inattendu du pipeline.",
exception,
extra_secrets=_settings_secrets(settings),
)
return 1
secrets = _settings_secrets(settings)
for error in errors:
logger.error("%s", redact_secrets(error.message, extra_secrets=secrets))
if data is None:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -261,3 +261,23 @@ class Settings(BaseSettings):
ai: AISettings = Field(default_factory=AISettings) ai: AISettings = Field(default_factory=AISettings)
blog: BlogSettings = Field(default_factory=BlogSettings) blog: BlogSettings = Field(default_factory=BlogSettings)
app: AppSettings = Field(default_factory=AppSettings) app: AppSettings = Field(default_factory=AppSettings)
def redaction_secrets(self) -> tuple[SecretStr, ...]:
"""Énumère tous les secrets configurés pour la rédaction.
Collecte les valeurs :class:`pydantic.SecretStr` non vides présentes
dans les sous-configurations (Pronote, CalDAV, XMPP, IA). Les valeurs
vides ou ``None`` sont filtrées ; les doublons sont supprimés.
:return: Tuple de secrets à masquer dans les messages d'erreur.
:rtype: tuple[SecretStr, ...]
"""
secrets = [
self.pronote.ical_url,
self.pronote.password,
self.caldav.url,
self.caldav.password,
self.xmpp.password,
self.ai.api_key,
]
return tuple(dict.fromkeys(secret for secret in secrets if secret is not None))

View File

@@ -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,
)

View File

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

View File

@@ -0,0 +1,299 @@
"""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._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)),
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.
: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 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)

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
"""É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 result.error is not None:
raise RuntimeError(result.error) from None
if not result.not_modified:
state.add_guids(article.id for article in result.articles)
state.update_cache_headers(result.etag, result.last_modified)
return list(result.articles)
except Exception as exc:
raise RuntimeError(f"Récupération du blog échouée : {redact_exception(exc)}") from None

View File

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

View File

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

View File

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

View File

@@ -29,6 +29,8 @@ class BlogRSSFetchResult(BaseModel):
réponse RSS, si elle est disponible. ``None`` par défaut. réponse RSS, si elle est disponible. ``None`` par défaut.
:param not_modified: Vaut ``True`` si le serveur a répondu avec le :param not_modified: Vaut ``True`` si le serveur a répondu avec le
statut ``304 Not Modified``, ``False`` sinon. statut ``304 Not Modified``, ``False`` sinon.
:param error: Message d'erreur expurgé si la récupération a échoué,
``None`` sinon.
""" """
model_config = ConfigDict(frozen=True) model_config = ConfigDict(frozen=True)
@@ -52,3 +54,7 @@ class BlogRSSFetchResult(BaseModel):
default=False, default=False,
description="Vaut True si le serveur a répondu 304 Not Modified", description="Vaut True si le serveur a répondu 304 Not Modified",
) )
error: str | None = Field(
default=None,
description=("Message d'erreur expurgé si la récupération a échoué, None sinon"),
)

View File

@@ -131,12 +131,14 @@ class BlogRSSClient:
if getattr(feed, "bozo", None): if getattr(feed, "bozo", None):
bozo_exception = getattr(feed, "bozo_exception", None) bozo_exception = getattr(feed, "bozo_exception", None)
if bozo_exception is not None: if bozo_exception is not None:
error_msg = f"Flux RSS invalide : {redact_exception(bozo_exception)}"
logger.warning( logger.warning(
"Flux RSS du blog invalide (%s), ignoré : %s", "Flux RSS du blog invalide (%s), ignoré : %s",
redact_exception(bozo_exception), redact_exception(bozo_exception),
redact_url(self.rss_url), redact_url(self.rss_url),
) )
else: else:
error_msg = "Flux RSS invalide"
logger.warning( logger.warning(
"Flux RSS du blog invalide, ignoré : %s", "Flux RSS du blog invalide, ignoré : %s",
redact_url(self.rss_url), redact_url(self.rss_url),
@@ -146,6 +148,7 @@ class BlogRSSClient:
etag=etag, etag=etag,
last_modified=last_modified, last_modified=last_modified,
not_modified=False, not_modified=False,
error=error_msg,
) )
articles: list[BlogArticle] = [] articles: list[BlogArticle] = []
@@ -234,16 +237,18 @@ class BlogRSSClient:
not_modified=False, not_modified=False,
) )
except Exception as exc: except Exception as exc:
error_msg = redact_exception(exc)
logger.error( logger.error(
"Échec de la récupération du flux RSS du blog %s : %s", "Échec de la récupération du flux RSS du blog %s : %s",
redact_url(self.rss_url), redact_url(self.rss_url),
redact_exception(exc), error_msg,
) )
return BlogRSSFetchResult( return BlogRSSFetchResult(
articles=(), articles=(),
etag=etag, etag=etag,
last_modified=last_modified, last_modified=last_modified,
not_modified=False, not_modified=False,
error=error_msg,
) )
@staticmethod @staticmethod

View File

@@ -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.

View File

@@ -89,7 +89,9 @@ def redact_secrets(text: str, extra_secrets: Iterable[SecretStr | str] = ()) ->
(clés API brutes, jetons, mots de passe, etc.) sont ensuite remplacées (clés API brutes, jetons, mots de passe, etc.) sont ensuite remplacées
littéralement, par ``str.replace``, par ``REDACTED`` dans le texte, y littéralement, par ``str.replace``, par ``REDACTED`` dans le texte, y
compris lorsqu'elles n'apparaissent pas sous une forme ``cle=valeur`` compris lorsqu'elles n'apparaissent pas sous une forme ``cle=valeur``
reconnue. Une valeur vide ou ``None`` est ignorée. reconnue. Une valeur vide ou ``None`` est ignorée. Les secrets sont
appliqués du plus long au plus court afin qu'un secret qui est une
sous-chaîne d'un autre soit remplacé en premier, sans être corrompu.
:param text: Texte pouvant contenir des URLs ou des secrets en clair. :param text: Texte pouvant contenir des URLs ou des secrets en clair.
:param extra_secrets: Itérable de secrets bruts (``str`` ou :param extra_secrets: Itérable de secrets bruts (``str`` ou
@@ -101,19 +103,25 @@ def redact_secrets(text: str, extra_secrets: Iterable[SecretStr | str] = ()) ->
redacted = _URL_PATTERN.sub(lambda match: redact_url(match.group(0)), text) redacted = _URL_PATTERN.sub(lambda match: redact_url(match.group(0)), text)
redacted = _AUTH_HEADER_PATTERN.sub(r"\1: REDACTED", redacted) redacted = _AUTH_HEADER_PATTERN.sub(r"\1: REDACTED", redacted)
redacted = _ISOLATED_SECRET_PATTERN.sub(r"\1\2\3REDACTED", redacted) redacted = _ISOLATED_SECRET_PATTERN.sub(r"\1\2\3REDACTED", redacted)
values: list[str] = []
for secret in extra_secrets: for secret in extra_secrets:
value: str | None = secret.get_secret_value() if isinstance(secret, SecretStr) else secret value: str | None = secret.get_secret_value() if isinstance(secret, SecretStr) else secret
if not value: if not value:
continue continue
values.append(value)
for value in sorted(values, key=len, reverse=True):
redacted = redacted.replace(value, _REDACTED) redacted = redacted.replace(value, _REDACTED)
return redacted return redacted
def redact_exception(exc: Exception) -> str: def redact_exception(exc: Exception, extra_secrets: Iterable[SecretStr | str] = ()) -> str:
"""Masque les secrets dans la représentation textuelle d'une exception. """Masque les secrets dans la représentation textuelle d'une exception.
:param exc: Exception dont le message doit être rédigé. :param exc: Exception dont le message doit être rédigé.
:param extra_secrets: Itérable de secrets bruts (``str`` ou
:class:`pydantic.SecretStr`) à masquer, transmis à
:func:`redact_secrets`. Les valeurs vides ou ``None`` sont ignorées.
:return: Représentation textuelle de l'exception avec les secrets masqués. :return: Représentation textuelle de l'exception avec les secrets masqués.
:rtype: str :rtype: str
""" """
return redact_secrets(str(exc)) return redact_secrets(str(exc), extra_secrets)

1
tests/e2e/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Tests end-to-end de l'interface en ligne de commande."""

189
tests/e2e/test_cli.py Normal file
View File

@@ -0,0 +1,189 @@
"""Tests de l'interface en ligne de commande ``pronote-sync``."""
from __future__ import annotations
import pytest
from pydantic import SecretStr
from pytest_mock import MockerFixture
from pronote_sync.config.settings import AISettings, AppSettings, PronoteSettings, Settings
from pronote_sync.errors import PipelineCriticalError, PipelineWarning
from pronote_sync.models.pronote import PronoteData
def test_main_runs_composition_root_in_dry_run_with_requested_log_level(
mocker: MockerFixture,
) -> None:
"""La CLI propage les options au logger et au runner injecté."""
from pronote_sync.cli.main import main
settings = Settings(app=AppSettings(log_level="WARNING"))
load_settings = mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings)
setup_logging = mocker.patch("pronote_sync.cli.main.setup_logging")
runner = mocker.Mock()
runner.run.return_value = (mocker.Mock(spec=PronoteData), [])
composition_root = mocker.patch(
"pronote_sync.cli.main.PipelineRunner.from_settings", return_value=runner
)
exit_code = main(["--dry-run", "--log-level", "DEBUG"])
assert exit_code == 0
load_settings.assert_called_once_with()
assert setup_logging.call_args_list == [mocker.call("DEBUG"), mocker.call("DEBUG")]
composition_root.assert_called_once_with(settings, dry_run=True)
runner.run.assert_called_once_with()
def test_main_preserves_configured_dry_run_and_returns_success_with_warnings(
mocker: MockerFixture,
) -> None:
"""Sans option, la CLI préserve le dry-run configuré et accepte les avertissements."""
from pronote_sync.cli.main import main
settings = Settings(app=AppSettings(dry_run=True, log_level="WARNING"))
mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings)
setup_logging = mocker.patch("pronote_sync.cli.main.setup_logging")
runner = mocker.Mock()
runner.run.return_value = (
mocker.Mock(spec=PronoteData),
[PipelineWarning("Avertissement non bloquant")],
)
composition_root = mocker.patch(
"pronote_sync.cli.main.PipelineRunner.from_settings", return_value=runner
)
exit_code = main([])
assert exit_code == 0
assert setup_logging.call_args_list == [mocker.call("INFO"), mocker.call("WARNING")]
composition_root.assert_called_once_with(settings, dry_run=None)
runner.run.assert_called_once_with()
def test_main_returns_failure_and_redacts_pipeline_secrets_at_debug_level(
mocker: MockerFixture,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Les diagnostics de pipeline restent expurgés, même au niveau DEBUG."""
from pronote_sync.cli.main import main
secret = "M12_PIPELINE_SECRET" # pragma: allowlist secret
settings = Settings(ai=AISettings(api_key=SecretStr(secret)))
mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings)
runner = mocker.Mock()
runner.run.return_value = (
None,
[PipelineCriticalError(f"Échec distant avec le secret {secret}")],
)
mocker.patch("pronote_sync.cli.main.PipelineRunner.from_settings", return_value=runner)
exit_code = main(["--log-level", "DEBUG"])
output = capsys.readouterr().out
assert exit_code == 1
assert secret not in output
assert "REDACTED" in output
assert "Traceback" not in output
def test_main_displays_a_redacted_configuration_traceback_at_debug_level(
mocker: MockerFixture,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Une erreur de configuration DEBUG conserve son traceback sans son secret."""
from pronote_sync.cli.main import main
secret = "M12_CONFIGURATION_SECRET" # pragma: allowlist secret
mocker.patch(
"pronote_sync.cli.main.load_settings",
side_effect=ValueError(f"configuration invalide: {secret}"),
)
exit_code = main(["--log-level", "DEBUG"])
output = capsys.readouterr().out
assert exit_code == 1
assert secret not in output
assert "Configuration invalide ou indisponible." in output
assert "Traceback" in output
def test_main_does_not_disclose_a_configured_pronote_username(
mocker: MockerFixture,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Les erreurs critiques ne divulguent pas un identifiant Pronote configuré."""
from pronote_sync.cli.main import main
username = "m12-parent-identifier"
settings = Settings(pronote=PronoteSettings(username=username))
mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings)
runner = mocker.Mock()
runner.run.return_value = (
None,
[PipelineCriticalError(f"Échec distant pour l'identifiant {username}")],
)
mocker.patch("pronote_sync.cli.main.PipelineRunner.from_settings", return_value=runner)
exit_code = main([])
output = capsys.readouterr().out
assert exit_code == 1
assert username not in output
assert "REDACTED" in output
def test_main_rejects_an_unknown_log_level() -> None:
"""La CLI rejette les niveaux de journalisation hors contrat."""
from pronote_sync.cli.main import main
with pytest.raises(SystemExit) as error:
main(["--log-level", "VERBOSE"])
assert error.value.code == 2
def test_main_logs_redacted_traceback_when_pipeline_raises_unexpectedly(
mocker: MockerFixture,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Une exception inattendue du pipeline produit un traceback expurgé en DEBUG."""
from pronote_sync.cli.main import main
secret = "M12_UNEXPECTED_SECRET" # pragma: allowlist secret
settings = Settings(ai=AISettings(api_key=SecretStr(secret)))
mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings)
mocker.patch(
"pronote_sync.cli.main.PipelineRunner.from_settings",
side_effect=RuntimeError(f"Erreur interne avec {secret}"),
)
exit_code = main(["--log-level", "DEBUG"])
output = capsys.readouterr().out
assert exit_code == 1
assert secret not in output
assert "Traceback" in output
assert "erreur expurgée" in output
def test_main_does_not_show_traceback_at_info_level(
mocker: MockerFixture,
capsys: pytest.CaptureFixture[str],
) -> None:
"""En niveau INFO, aucune pile n'est affichée pour une erreur inattendue."""
from pronote_sync.cli.main import main
mocker.patch("pronote_sync.cli.main.load_settings", return_value=Settings())
mocker.patch(
"pronote_sync.cli.main.PipelineRunner.from_settings",
side_effect=RuntimeError("Erreur interne"),
)
exit_code = main([])
output = capsys.readouterr().out
assert exit_code == 1
assert "Traceback" not in output
assert "Échec inattendu du pipeline." in output

File diff suppressed because it is too large Load Diff