"""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()