Compare commits

...
Author SHA1 Message Date
Codex 46d9942c56 fix(blog): confirmer l'état mémoire après sauvegarde 2026-09-13 00:14:38 +02:00
2 changed files with 63 additions and 9 deletions
+26 -9
View File
@@ -97,7 +97,10 @@ class BlogRSSState:
redact_exception(exc),
)
def _save(self) -> None:
def _save(
self,
state: tuple[set[str], str | None, str | None] | None = None,
) -> bool:
"""Sauvegarde l'état dans le fichier JSON de manière atomique.
La sortie est déterministe : ``known_guids`` est trié
@@ -107,20 +110,30 @@ class BlogRSSState:
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é.
:param state: État à sauvegarder ; l'état courant est utilisé par défaut.
:return: ``True`` si l'état a été sauvegardé ou si la persistance est désactivée.
:rtype: bool
"""
if not self._persistence_enabled:
return
return True
known_guids, etag, last_modified = state or (
self._known_guids,
self._etag,
self._last_modified,
)
payload = {
"version": _STATE_VERSION,
"known_guids": sorted(self._known_guids),
"etag": self._etag,
"last_modified": self._last_modified,
"known_guids": sorted(known_guids),
"etag": etag,
"last_modified": 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)
return True
except Exception as exc:
logger.error(
"Impossible d'écrire le fichier d'état blog RSS %s : %s.",
@@ -134,6 +147,7 @@ class BlogRSSState:
"Nettoyage du fichier temporaire échoué : %s",
redact_exception(cleanup_exc),
)
return False
def get_known_guids(self) -> frozenset[str]:
"""Renvoie une copie immuable des GUID d'articles déjà connus.
@@ -168,10 +182,13 @@ class BlogRSSState:
"""
if result.not_modified:
return
self._known_guids.update(article.id for article in result.articles)
self._etag = result.etag
self._last_modified = result.last_modified
self._save()
new_state = (
self._known_guids | {article.id for article in result.articles},
result.etag,
result.last_modified,
)
if self._save(new_state):
self._known_guids, self._etag, self._last_modified = new_state
def get_cache_headers(self) -> tuple[str | None, str | None]:
"""Renvoie les en-têtes de cache HTTP mémorisés.
+37
View File
@@ -425,4 +425,41 @@ def test_atomic_save_preserves_on_error(tmp_path: Path) -> None:
assert state.get_known_guids() == frozenset({"original-guid-1", "original-guid-2", "new-guid"})
def test_acknowledge_does_not_advance_memory_when_save_fails(
tmp_path: Path,
) -> None:
"""Conserve l'état précédent en mémoire si l'acquittement ne peut pas être sauvegardé.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
state = BlogRSSState(state_file)
state.add_guids(["existing-guid"])
state.update_cache_headers("old-etag", "old-last-modified")
article = BlogArticle(
id="new-guid",
title="Article",
url="https://example.com/article",
published_at=datetime(2026, 9, 12, 8, 0, tzinfo=UTC),
updated_at=None,
category=None,
author=None,
content_html="<p>Contenu</p>",
content_text="Contenu",
)
with patch.object(Path, "replace", side_effect=OSError("replace failed")):
state.acknowledge(
BlogRSSFetchResult(
articles=(article,),
etag="new-etag",
last_modified="new-last-modified",
)
)
assert state.get_known_guids() == frozenset({"existing-guid"})
assert state.get_cache_headers() == ("old-etag", "old-last-modified")
# Ensure trailing newline