From 1019b2280886d5597b48b41df467c235700d3914 Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Tue, 8 Sep 2026 12:47:42 +0200 Subject: [PATCH] docs(M11): align GUIDE sections 4.2.1 and 11.3 with FIXME_M11 corrections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .secrets.baseline | 4 +- GUIDE_DEV_PYTHON.md | 201 ++++++++++++++++++++++++++------------------ TODO.md | 1 + 3 files changed, 121 insertions(+), 85 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 108ce07..8364877 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -140,7 +140,7 @@ "filename": "GUIDE_DEV_PYTHON.md", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "is_verified": true, - "line_number": 5029, + "line_number": 5064, "is_secret": false } ], @@ -177,5 +177,5 @@ } ] }, - "generated_at": "2026-09-08T00:29:02Z" + "generated_at": "2026-09-08T10:45:46Z" } diff --git a/GUIDE_DEV_PYTHON.md b/GUIDE_DEV_PYTHON.md index 8b0add9..a5e4352 100644 --- a/GUIDE_DEV_PYTHON.md +++ b/GUIDE_DEV_PYTHON.md @@ -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_url` utilise `urlsplit`/`urlunsplit`/`parse_qsl` au lieu de `urlparse`/`urlunparse`/`parse_qs`. > 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 import re +from typing import Iterable, SecretStr from urllib.parse import urlparse, urlunparse, parse_qs, urlencode @@ -543,6 +546,20 @@ def redact_secrets(text: str) -> str: ) 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`) @@ -4576,6 +4593,7 @@ class PipelineRunner: blog_rss_client: Optional["BlogRSSClient"] = None, blog_state: Optional["## (section obsolète supprimée)"] = None, dry_run: bool = False, + settings: "Settings" | None = None, ): self.pronote_fetcher = pronote_fetcher self.caldav_client = caldav_client @@ -4587,13 +4605,19 @@ class PipelineRunner: self.dry_run = dry_run self._errors: List[PipelineError] = [] 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]]: """ Exécute le pipeline complet. - Returns: - Tuple (PronoteData final, liste des erreurs). + :return: Tuple (PronoteData final, liste des erreurs). + :rtype: tuple[PronoteData | None, list[PipelineError]] """ pronote_data: Optional[PronoteData] = None agenda_diff = None @@ -4622,73 +4646,81 @@ class PipelineRunner: logger.warning(f"Étape 'normalize' échouée: {e.message}") return None, self._errors + self._warnings - # Étape 2 bis: Récupération du blog (RSS) - if self.blog_rss_client and self.blog_state: - try: - blog_articles = fetch_blog_step( - self.blog_rss_client, - self.blog_state, - enabled=True, - ) - except PipelineError as e: - self._warnings.append(PipelineWarning( - message=f"Récupération du blog échouée: {e.message}", - step="fetch_blog", - )) - logger.warning(f"Étape 'fetch_blog' échouée (non bloquante): {e.message}") - blog_articles = [] + # Étape 2 bis: Récupération du blog (RSS) + if self.blog_rss_client and self.blog_state: + try: + blog_articles = fetch_blog_step( + self.blog_rss_client, + self.blog_state, + enabled=True, + ) + except PipelineCriticalError: + raise + except PipelineError as e: + self._warnings.append(PipelineWarning( + message=f"Récupération du blog échouée: {e.message}", + step="fetch_blog", + )) + logger.warning(f"Étape 'fetch_blog' échouée (non bloquante): {e.message}") + blog_articles = [] - # Étape 3: Comparaison avec l'agenda théorique - try: - agenda_diff = compare_step( - self.agenda_comparator, - pronote_data.lessons, - pronote_data.target_date, - ) - except PipelineError as e: - self._warnings.append(PipelineWarning( - message=f"Comparaison échouée: {e.message}", - step="compare", - )) - logger.warning(f"Étape 'compare' échouée (non bloquante): {e.message}") + # Étape 3: Comparaison avec l'agenda théorique + try: + agenda_diff = compare_step( + self.agenda_comparator, + pronote_data.lessons, + pronote_data.target_date, + ) + except PipelineCriticalError: + raise + except PipelineError as e: + self._warnings.append(PipelineWarning( + message=f"Comparaison échouée: {e.message}", + step="compare", + )) + logger.warning(f"Étape 'compare' échouée (non bloquante): {e.message}") - # Étape 4: Synchronisation CalDAV - try: - sync_result = caldav_sync_step( - self.caldav_client, - pronote_data.lessons, - pronote_data.homeworks, - pronote_data.school_events, - ) - if sync_result and sync_result.status.value == "failed": - self._warnings.append(PipelineWarning( - message=f"Synchronisation CalDAV échouée: {sync_result.errors}", - step="sync", - )) - logger.warning("Synchronisation CalDAV échouée (non bloquante)") - except PipelineError as e: - self._warnings.append(PipelineWarning( - message=f"Synchronisation CalDAV échouée: {e.message}", - step="sync", - )) - logger.warning(f"Étape 'sync' échouée (non bloquante): {e.message}") + # Étape 4: Synchronisation CalDAV + try: + sync_result = caldav_sync_step( + self.caldav_client, + pronote_data.lessons, + pronote_data.homeworks, + pronote_data.school_events, + ) + if sync_result and sync_result.status.value == "failed": + self._warnings.append(PipelineWarning( + message=f"Synchronisation CalDAV échouée: {sync_result.errors}", + step="sync", + )) + logger.warning("Synchronisation CalDAV échouée (non bloquante)") + except PipelineCriticalError: + raise + except PipelineError as e: + self._warnings.append(PipelineWarning( + message=f"Synchronisation CalDAV échouée: {e.message}", + step="sync", + )) + logger.warning(f"Étape 'sync' échouée (non bloquante): {e.message}") - # Étape 5: Synthèse IA (optionnelle) - if self.synthesis_provider and agenda_diff: - try: - synthesis_input = SynthesisInput( - agenda_diff=agenda_diff, - messages=pronote_data.messages, - school_events=pronote_data.school_events, - target_date=pronote_data.target_date, - ) - synthesis_result = synthesis_step(self.synthesis_provider, synthesis_input) - except PipelineError as e: - self._warnings.append(PipelineWarning( - message=f"Synthèse IA échouée: {e.message}", - step="synthesis", - )) - logger.warning(f"Étape 'synthesis' échouée (non bloquante): {e.message}") + # Étape 5: Synthèse IA (optionnelle) + if self.synthesis_provider and agenda_diff: + try: + synthesis_input = SynthesisInput( + agenda_diff=agenda_diff, + messages=pronote_data.messages, + school_events=pronote_data.school_events, + target_date=pronote_data.target_date, + ) + synthesis_result = synthesis_step(self.synthesis_provider, synthesis_input) + except PipelineCriticalError: + raise + except PipelineError as e: + self._warnings.append(PipelineWarning( + message=f"Synthèse IA échouée: {e.message}", + step="synthesis", + )) + logger.warning(f"Étape 'synthesis' échouée (non bloquante): {e.message}") # Étape 6: Construction du message XMPP xmpp_message = XmppMessage( @@ -4702,29 +4734,30 @@ class PipelineRunner: ) if blog_articles else None, ) - # Étape 7: Envoi XMPP - try: - send_step(self.channel, xmpp_message) - except PipelineError as e: - self._warnings.append(PipelineWarning( - message=f"Envoi XMPP échoué: {e.message}", - step="send", - )) - logger.warning(f"Étape 'send' échouée (non bloquante): {e.message}") + # Étape 7: Envoi XMPP + try: + send_step(self.channel, xmpp_message) + except PipelineCriticalError: + raise + except PipelineError as e: + self._warnings.append(PipelineWarning( + message=f"Envoi XMPP échoué: {e.message}", + step="send", + )) + logger.warning(f"Étape 'send' échouée (non bloquante): {e.message}") return pronote_data, self._errors + self._warnings except PipelineCriticalError as e: logger.error(f"Erreur critique dans le pipeline: {e.message}") return None, [e] - except Exception as e: - from ..utils.redaction import redact_secrets - safe_error = redact_secrets(str(e)) - logger.error(f"Erreur inattendue dans le pipeline: {safe_error}") - return None, [PipelineCriticalError( - message=safe_error, - step="unknown", - )] + except Exception as e: + safe_error = self._redact(e) + logger.error(f"Erreur inattendue dans le pipeline: {safe_error}") + return None, [PipelineCriticalError( + message=safe_error, + step="unknown", + )] def get_errors(self) -> List[PipelineError]: """Récupère la liste des erreurs.""" @@ -4735,6 +4768,8 @@ class PipelineRunner: 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/`) diff --git a/TODO.md b/TODO.md index 4e07213..d8ba6de 100644 --- a/TODO.md +++ b/TODO.md @@ -233,6 +233,7 @@ Composer et orchestrer toutes les étapes avec gestion d'erreurs dégradée et m - [x] Une erreur non critique (ex : synthèse IA) n'empêche pas l'envoi XMPP. - [x] `dry_run=True` n'effectue aucune écriture ; aucune source disponible → erreur critique explicite. - [x] Si `THEORETICAL_AGENDA_PATH` est absent, le pipeline produit un diff vide sans erreur et n'instancie pas `AgendaComparator` ; si présent, il instancie le comparateur et effectue la comparaison. +- [x] Les erreurs critiques (`PipelineCriticalError`) propagées depuis une étape non-bloquante arrêtent le pipeline. ---