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>
162 lines
6.3 KiB
Python
162 lines
6.3 KiB
Python
"""Gestion de l'état local du flux RSS du blog du collège.
|
|
|
|
Ce module définit :class:`BlogRSSState`, un gestionnaire d'état persistant
|
|
dans un fichier JSON local (``.blog_rss_state.json`` par défaut). Il
|
|
mémorise les identifiants (GUID) des articles déjà traités — pour la
|
|
déduplication — ainsi que les en-têtes HTTP ``ETag`` et ``Last-Modified``
|
|
de la dernière réponse — pour les requêtes conditionnelles.
|
|
|
|
La lecture et l'écriture sont tolérantes aux erreurs : un fichier absent,
|
|
corrompu ou illisible ne fait jamais échouer le pipeline ; l'état vide est
|
|
alors utilisé. La sortie JSON est déterministe (``known_guids`` triés
|
|
alphabétiquement, champ ``version`` constant).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from collections.abc import Iterable
|
|
from pathlib import Path
|
|
|
|
from pronote_sync.utils.redaction import redact_exception, redact_secrets
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_STATE_VERSION = 1
|
|
|
|
|
|
class BlogRSSState:
|
|
"""Gère l'état local pour la déduplication des articles et le cache HTTP du flux RSS.
|
|
|
|
L'état regroupe l'ensemble des GUID d'articles déjà publiés
|
|
(``known_guids``) et les en-têtes de cache HTTP (``etag``,
|
|
``last_modified``). Il est chargé depuis le fichier JSON à la
|
|
construction et sauvegardé à chaque modification. Toute erreur de
|
|
lecture ou d'écriture est journalisée sans être propagée.
|
|
|
|
:param state_file: Chemin du fichier d'état JSON (``str`` ou
|
|
:class:`~pathlib.Path`). ``".blog_rss_state.json"`` par défaut.
|
|
"""
|
|
|
|
def __init__(self, state_file: Path | str = ".blog_rss_state.json") -> None:
|
|
"""Initialise le gestionnaire d'état depuis le fichier JSON.
|
|
|
|
:param state_file: Chemin du fichier d'état JSON (``str`` ou
|
|
:class:`~pathlib.Path`). ``".blog_rss_state.json"`` par défaut.
|
|
"""
|
|
self._state_file = Path(state_file)
|
|
self._known_guids: set[str] = set()
|
|
self._etag: str | None = None
|
|
self._last_modified: str | None = None
|
|
self._load()
|
|
|
|
def _load(self) -> None:
|
|
"""Charge l'état depuis le fichier JSON.
|
|
|
|
Si le fichier n'existe pas, l'état reste vide. Si le fichier est
|
|
corrompu, illisible ou que la version est absente ou différente
|
|
de 1, un avertissement est journalisé et l'état reste vide.
|
|
Aucune exception n'est propagée.
|
|
"""
|
|
if not self._state_file.exists():
|
|
return
|
|
try:
|
|
data = json.loads(self._state_file.read_text(encoding="utf-8"))
|
|
if not isinstance(data, dict) or data.get("version") != _STATE_VERSION:
|
|
logger.warning(
|
|
"Fichier d'état blog RSS %s : version absente ou non supportée, "
|
|
"démarrage avec un état vide.",
|
|
redact_secrets(str(self._state_file)),
|
|
)
|
|
return
|
|
guids_data = data.get("known_guids", [])
|
|
if isinstance(guids_data, list):
|
|
self._known_guids = {guid for guid in guids_data if isinstance(guid, str)}
|
|
etag_data = data.get("etag")
|
|
if isinstance(etag_data, str):
|
|
self._etag = etag_data
|
|
last_modified_data = data.get("last_modified")
|
|
if isinstance(last_modified_data, str):
|
|
self._last_modified = last_modified_data
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Impossible de charger le fichier d'état blog RSS %s : %s, "
|
|
"démarrage avec un état vide.",
|
|
redact_secrets(str(self._state_file)),
|
|
redact_exception(exc),
|
|
)
|
|
|
|
def _save(self) -> None:
|
|
"""Sauvegarde l'état dans le fichier JSON.
|
|
|
|
La sortie est déterministe : ``known_guids`` est trié
|
|
alphabétiquement et le champ ``version`` vaut 1. En cas d'erreur
|
|
d'écriture, une erreur est journalisée sans être propagée.
|
|
"""
|
|
payload = {
|
|
"version": _STATE_VERSION,
|
|
"known_guids": sorted(self._known_guids),
|
|
"etag": self._etag,
|
|
"last_modified": self._last_modified,
|
|
}
|
|
try:
|
|
with self._state_file.open("w", encoding="utf-8") as handle:
|
|
json.dump(payload, handle, indent=2)
|
|
except Exception as exc:
|
|
logger.error(
|
|
"Impossible d'écrire le fichier d'état blog RSS %s : %s.",
|
|
redact_secrets(str(self._state_file)),
|
|
redact_exception(exc),
|
|
)
|
|
|
|
def get_known_guids(self) -> frozenset[str]:
|
|
"""Renvoie une copie immuable des GUID d'articles déjà connus.
|
|
|
|
:return: Copie de type :class:`frozenset` des GUID connus.
|
|
:rtype: frozenset[str]
|
|
"""
|
|
return frozenset(self._known_guids)
|
|
|
|
def add_guids(self, guids: Iterable[str]) -> None:
|
|
"""Ajoute des GUID d'articles à l'état connu et sauvegarde.
|
|
|
|
Si l'itérable ne contient aucun GUID, l'état n'est pas modifié et
|
|
aucune sauvegarde n'est déclenchée.
|
|
|
|
:param guids: Itérable des GUID d'articles à enregistrer.
|
|
"""
|
|
new_guids = set(guids)
|
|
if not new_guids:
|
|
return
|
|
self._known_guids.update(new_guids)
|
|
self._save()
|
|
|
|
def get_cache_headers(self) -> tuple[str | None, str | None]:
|
|
"""Renvoie les en-têtes de cache HTTP mémorisés.
|
|
|
|
:return: Tuple ``(etag, last_modified)``, chaque valeur pouvant
|
|
être ``None`` si elle n'a jamais été reçue.
|
|
:rtype: tuple[str | None, str | None]
|
|
"""
|
|
return self._etag, self._last_modified
|
|
|
|
def update_cache_headers(self, etag: str | None, last_modified: str | None) -> None:
|
|
"""Met à jour les en-têtes de cache HTTP et sauvegarde.
|
|
|
|
:param etag: Nouvelle valeur de l'en-tête ``ETag``, ou ``None``
|
|
pour l'effacer.
|
|
:param last_modified: Nouvelle valeur de l'en-tête
|
|
``Last-Modified``, ou ``None`` pour l'effacer.
|
|
"""
|
|
self._etag = etag
|
|
self._last_modified = last_modified
|
|
self._save()
|
|
|
|
def clear(self) -> None:
|
|
"""Réinitialise l'état (GUID et en-têtes de cache) et sauvegarde."""
|
|
self._known_guids = set()
|
|
self._etag = None
|
|
self._last_modified = None
|
|
self._save()
|