feat(M5): source blog RSS — fetch, parsing, déduplication et état persistant
Implémentation complète de la source blog RSS du collège : - BlogRSSClient (sources/blog/rss.py) : client sans état récupérant et parsant le flux via feedparser, avec déduplication par ensemble de GUIDs connus, cache HTTP conditionnel (ETag/Last-Modified), conversion HTML→texte (BeautifulSoup), tri déterministe (date desc puis id asc), et mode dégradé (flux invalide/erreur → warning expurgé + liste vide). - BlogRSSFetchResult (sources/blog/result.py) : résultat immuable contenant articles, en-têtes de cache et indicateur not_modified. - BlogRSSState (sources/blog/state.py) : persistance JSON tolérante (GUIDs triés, version, ETag, Last-Modified) avec redaction des chemins dans les logs. - Fixture tests/fixtures/blog_rss.xml : flux RSS 2.0 anonymisé, 3 articles, dates fixes, ordre non chronologique. - 38 tests unitaires (22 client + 16 state) couvrant parsing nominal, déduplication intra-flux, 304, bozo, erreurs réseau, non-fuite de secrets, tri secondaire, persistance d'état et tolérance aux fichiers corrompus. - Documentation : TODO.md M5 coché, GUIDE_DEV_PYTHON.md §5 bis aligné avec l'API livrée (known_guids, BlogRSSFetchResult, BlogRSSState). - Configuration : feedparser ajouté aux additional_dependencies du hook mypy pre-commit pour aligner l'environnement isolé avec le .venv. Co-authored-by: opencode/coder <coder@agents.invalid> Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
This commit is contained in:
54
pronote_sync/sources/blog/result.py
Normal file
54
pronote_sync/sources/blog/result.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Résultat de la récupération du flux RSS du blog du collège.
|
||||
|
||||
Ce module définit :class:`BlogRSSFetchResult`, le type de retour figé du
|
||||
client RSS du blog (:mod:`pronote_sync.sources.blog`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from pronote_sync.models.blog import BlogArticle
|
||||
|
||||
|
||||
class BlogRSSFetchResult(BaseModel):
|
||||
"""Résultat d'une récupération du flux RSS du blog du collège.
|
||||
|
||||
Modèle figé (``frozen``) : les instances sont immuables après création.
|
||||
Il regroupe les nouveaux articles, triés par date de publication
|
||||
décroissante puis par identifiant croissant, ainsi que les en-têtes
|
||||
HTTP utiles aux requêtes conditionnelles (``ETag`` et
|
||||
``Last-Modified``).
|
||||
|
||||
:param articles: Nouveaux articles absents de ``known_guids``, triés
|
||||
par date de publication décroissante puis par identifiant
|
||||
croissant. Vide par défaut.
|
||||
:param etag: Valeur de l'en-tête ``ETag`` de la réponse RSS, si elle
|
||||
est disponible. ``None`` par défaut.
|
||||
:param last_modified: Valeur de l'en-tête ``Last-Modified`` de la
|
||||
réponse RSS, si elle est disponible. ``None`` par défaut.
|
||||
:param not_modified: Vaut ``True`` si le serveur a répondu avec le
|
||||
statut ``304 Not Modified``, ``False`` sinon.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
articles: tuple[BlogArticle, ...] = Field(
|
||||
default=(),
|
||||
description=(
|
||||
"Nouveaux articles absents de known_guids, triés par date de "
|
||||
"publication décroissante puis par identifiant croissant"
|
||||
),
|
||||
)
|
||||
etag: str | None = Field(
|
||||
default=None,
|
||||
description="Valeur de l'en-tête ETag de la réponse RSS, si disponible",
|
||||
)
|
||||
last_modified: str | None = Field(
|
||||
default=None,
|
||||
description="Valeur de l'en-tête Last-Modified de la réponse RSS, si disponible",
|
||||
)
|
||||
not_modified: bool = Field(
|
||||
default=False,
|
||||
description="Vaut True si le serveur a répondu 304 Not Modified",
|
||||
)
|
||||
Reference in New Issue
Block a user