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:
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/`)
|
||||||
|
|
||||||
|
|||||||
1
TODO.md
1
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] 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user