"""Contrat commun à tous les collecteurs.""" from __future__ import annotations import time from abc import ABC, abstractmethod from dataclasses import dataclass, field from datetime import date from pipeline.journal import logger from pipeline.modeles import DecisionCC, Evenement, Source, Statut, TypeTexte log = logger("collecteur") @dataclass(slots=True) class TexteCollecte: """Un texte observé sur une source officielle. Volontairement plus pauvre qu'un `Texte` : un collecteur rapporte ce qu'il voit, il ne classe pas. La classification et la cotation Guadeloupe sont faites ensuite, et signalées comme automatiques. """ titre: str source_url: str collecteur: str numero_officiel: str | None = None type: TypeTexte | None = None statut: Statut | None = None date_promulgation: date | None = None date_adoption: date | None = None date_publication_jo: date | None = None identifiant_externe: str | None = None # cid Légifrance, dossier AN… resume: str | None = None evenements: list[Evenement] = field(default_factory=list) decisions_cc: list[DecisionCC] = field(default_factory=list) sources: list[Source] = field(default_factory=list) @dataclass(slots=True) class ResultatCollecte: """Ce qu'un collecteur rapporte d'un passage.""" collecteur: str textes: list[TexteCollecte] = field(default_factory=list) decisions_cc: list[DecisionCC] = field(default_factory=list) erreurs: list[str] = field(default_factory=list) duree_s: float = 0.0 ignore: bool = False motif_ignore: str | None = None @property def a_reussi(self) -> bool: return not self.erreurs and not self.ignore class Collecteur(ABC): """Un collecteur de source officielle. Les implémentations ne lèvent jamais : `collecter()` capture ses propres erreurs et les range dans le résultat. C'est ce qui permet au run complet de se poursuivre quand une source est indisponible. """ nom: str = "inconnu" def est_disponible(self) -> tuple[bool, str | None]: """Indique si le collecteur peut travailler, et sinon pourquoi. Sert à documenter les dégradations : un collecteur privé de clé d'API doit le dire clairement plutôt que d'échouer silencieusement. """ return True, None @abstractmethod def _collecter(self) -> ResultatCollecte: """Collecte effective, susceptible de lever.""" def collecter(self) -> ResultatCollecte: depart = time.monotonic() disponible, motif = self.est_disponible() if not disponible: log.info("collecteur ignoré", collecteur=self.nom, motif=motif) return ResultatCollecte( collecteur=self.nom, ignore=True, motif_ignore=motif, duree_s=time.monotonic() - depart, ) try: resultat = self._collecter() except Exception as erreur: # noqa: BLE001 — un collecteur ne fait pas tomber le run log.warning("collecteur en échec", collecteur=self.nom, erreur=str(erreur)) resultat = ResultatCollecte(collecteur=self.nom, erreurs=[str(erreur)]) resultat.duree_s = time.monotonic() - depart log.info( "collecte terminée", collecteur=self.nom, textes=len(resultat.textes), decisions=len(resultat.decisions_cc), erreurs=len(resultat.erreurs), duree_s=round(resultat.duree_s, 2), ) return resultat