Corrige les 5 points de l'audit FIXME_M5 : 1. (Bloquant) Sépare transport HTTP et parsing : utilise requests.get() avec timeout explicite et en-têtes conditionnels, puis transmet le contenu à feedparser.parse() — supprime le paramètre inexistant request_timeout qui faisait échouer toute récupération réelle. 2. Rejette les statuts HTTP 4xx/5xx via raise_for_status() avant le parsing. 3. Préserve les validateurs de cache (etag, last_modified) d'entrée sur les chemins d'échec (exception, bozo) au lieu de les écraser à None. 4. Sauvegarde atomique de BlogRSSState : écrit dans un .tmp puis Path.replace() pour éviter la corruption sur interruption. 5. Déduplication normale silencieuse : les GUID déjà connus sont ignorés sans warning ; seuls les doublons intra-flux génèrent un avertissement. Tests : 49 tests (32 client + 17 state) dont 11 nouveaux couvrant transport HTTP réel, statuts 401/404/500, préservation des validateurs, en-têtes conditionnels, doublons intra-flux et sauvegarde atomique. Guide : §5 bis.7.1 aligné avec le nouveau pattern transport/parsing. Co-authored-by: opencode/coder <coder@agents.invalid> Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
175 lines
6.9 KiB
Python
175 lines
6.9 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 de manière atomique.
|
|
|
|
La sortie est déterministe : ``known_guids`` est trié
|
|
alphabétiquement et le champ ``version`` vaut 1. Le JSON est
|
|
d'abord écrit dans un fichier temporaire du même répertoire, puis
|
|
remplacé atomiquement par :meth:`~pathlib.Path.replace` afin de ne
|
|
jamais laisser un fichier partiel en cas d'interruption. En cas
|
|
d'erreur d'écriture, une erreur est journalisée sans être
|
|
propagée et le fichier temporaire est supprimé.
|
|
"""
|
|
payload = {
|
|
"version": _STATE_VERSION,
|
|
"known_guids": sorted(self._known_guids),
|
|
"etag": self._etag,
|
|
"last_modified": self._last_modified,
|
|
}
|
|
tmp_file = self._state_file.with_suffix(".tmp")
|
|
try:
|
|
with open(tmp_file, "w", encoding="utf-8") as handle:
|
|
json.dump(payload, handle, indent=2)
|
|
tmp_file.replace(self._state_file)
|
|
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),
|
|
)
|
|
try:
|
|
tmp_file.unlink(missing_ok=True)
|
|
except Exception as cleanup_exc:
|
|
logger.debug(
|
|
"Nettoyage du fichier temporaire échoué : %s",
|
|
redact_exception(cleanup_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()
|