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>
This commit is contained in:
2026-09-08 12:47:42 +02:00
parent 28c695795a
commit 2232e4fb7d
3 changed files with 121 additions and 85 deletions

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/`)

View File

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