Compare commits

...

4 Commits

Author SHA1 Message Date
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
21 changed files with 1938 additions and 109 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
@@ -4630,6 +4654,8 @@ class PipelineRunner:
self.blog_state, self.blog_state,
enabled=True, enabled=True,
) )
except PipelineCriticalError:
raise
except PipelineError as e: except PipelineError as e:
self._warnings.append(PipelineWarning( self._warnings.append(PipelineWarning(
message=f"Récupération du blog échouée: {e.message}", message=f"Récupération du blog échouée: {e.message}",
@@ -4645,6 +4671,8 @@ class PipelineRunner:
pronote_data.lessons, pronote_data.lessons,
pronote_data.target_date, pronote_data.target_date,
) )
except PipelineCriticalError:
raise
except PipelineError as e: except PipelineError as e:
self._warnings.append(PipelineWarning( self._warnings.append(PipelineWarning(
message=f"Comparaison échouée: {e.message}", message=f"Comparaison échouée: {e.message}",
@@ -4666,6 +4694,8 @@ class PipelineRunner:
step="sync", step="sync",
)) ))
logger.warning("Synchronisation CalDAV échouée (non bloquante)") logger.warning("Synchronisation CalDAV échouée (non bloquante)")
except PipelineCriticalError:
raise
except PipelineError as e: except PipelineError as e:
self._warnings.append(PipelineWarning( self._warnings.append(PipelineWarning(
message=f"Synchronisation CalDAV échouée: {e.message}", message=f"Synchronisation CalDAV échouée: {e.message}",
@@ -4683,6 +4713,8 @@ class PipelineRunner:
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 PipelineCriticalError:
raise
except PipelineError as e: except PipelineError as e:
self._warnings.append(PipelineWarning( self._warnings.append(PipelineWarning(
message=f"Synthèse IA échouée: {e.message}", message=f"Synthèse IA échouée: {e.message}",
@@ -4705,6 +4737,8 @@ class PipelineRunner:
# Étape 7: Envoi XMPP # Étape 7: Envoi XMPP
try: try:
send_step(self.channel, xmpp_message) send_step(self.channel, xmpp_message)
except PipelineCriticalError:
raise
except PipelineError as e: except PipelineError as e:
self._warnings.append(PipelineWarning( self._warnings.append(PipelineWarning(
message=f"Envoi XMPP échoué: {e.message}", message=f"Envoi XMPP échoué: {e.message}",
@@ -4718,8 +4752,7 @@ class PipelineRunner:
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,
@@ -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/`)

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

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)

File diff suppressed because it is too large Load Diff