fix: prevent source state writes in dry-run

This commit is contained in:
2026-09-10 23:57:50 +02:00
parent 999ed76ba7
commit 5e69c4e049
4 changed files with 167 additions and 3 deletions

View File

@@ -37,15 +37,24 @@ class BlogRSSState:
:param state_file: Chemin du fichier d'état JSON (``str`` ou :param state_file: Chemin du fichier d'état JSON (``str`` ou
:class:`~pathlib.Path`). ``".blog_rss_state.json"`` par défaut. :class:`~pathlib.Path`). ``".blog_rss_state.json"`` par défaut.
:param persistence_enabled: Si ``False``, charge l'état existant mais ne
modifie jamais le fichier d'état. ``True`` par défaut.
""" """
def __init__(self, state_file: Path | str = ".blog_rss_state.json") -> None: def __init__(
self,
state_file: Path | str = ".blog_rss_state.json",
persistence_enabled: bool = True,
) -> None:
"""Initialise le gestionnaire d'état depuis le fichier JSON. """Initialise le gestionnaire d'état depuis le fichier JSON.
:param state_file: Chemin du fichier d'état JSON (``str`` ou :param state_file: Chemin du fichier d'état JSON (``str`` ou
:class:`~pathlib.Path`). ``".blog_rss_state.json"`` par défaut. :class:`~pathlib.Path`). ``".blog_rss_state.json"`` par défaut.
:param persistence_enabled: Si ``False``, charge l'état existant mais
désactive toutes les écritures sur disque. ``True`` par défaut.
""" """
self._state_file = Path(state_file) self._state_file = Path(state_file)
self._persistence_enabled = persistence_enabled
self._known_guids: set[str] = set() self._known_guids: set[str] = set()
self._etag: str | None = None self._etag: str | None = None
self._last_modified: str | None = None self._last_modified: str | None = None
@@ -98,6 +107,8 @@ class BlogRSSState:
d'erreur d'écriture, une erreur est journalisée sans être d'erreur d'écriture, une erreur est journalisée sans être
propagée et le fichier temporaire est supprimé. propagée et le fichier temporaire est supprimé.
""" """
if not self._persistence_enabled:
return
payload = { payload = {
"version": _STATE_VERSION, "version": _STATE_VERSION,
"known_guids": sorted(self._known_guids), "known_guids": sorted(self._known_guids),

View File

@@ -38,9 +38,15 @@ class PronoteAuthState:
:param state_file: Chemin du fichier d'état JSON (``str`` ou :param state_file: Chemin du fichier d'état JSON (``str`` ou
:class:`~pathlib.Path`). ``".pronote_auth_state.json"`` par défaut. :class:`~pathlib.Path`). ``".pronote_auth_state.json"`` par défaut.
:param persistence_enabled: Si ``False``, charge l'état existant mais ne
modifie jamais le fichier d'état. ``True`` par défaut.
""" """
def __init__(self, state_file: Path | str = ".pronote_auth_state.json") -> None: def __init__(
self,
state_file: Path | str = ".pronote_auth_state.json",
persistence_enabled: bool = True,
) -> None:
"""Initialise le gestionnaire d'état d'authentification Pronote. """Initialise le gestionnaire d'état d'authentification Pronote.
Le fichier d'état n'est pas créé à l'initialisation : il n'est écrit Le fichier d'état n'est pas créé à l'initialisation : il n'est écrit
@@ -48,8 +54,14 @@ class PronoteAuthState:
:param state_file: Chemin du fichier d'état JSON (``str`` ou :param state_file: Chemin du fichier d'état JSON (``str`` ou
:class:`~pathlib.Path`). ``".pronote_auth_state.json"`` par défaut. :class:`~pathlib.Path`). ``".pronote_auth_state.json"`` par défaut.
:param persistence_enabled: Si ``False``, charge l'état existant mais
désactive toutes les écritures ou suppressions sur disque. ``True``
par défaut.
""" """
self._state_file = Path(state_file) self._state_file = Path(state_file)
self._persistence_enabled = persistence_enabled
self._in_memory_credentials: dict[str, str] | None = None
self._in_memory_state_cleared = False
def load(self) -> dict[str, str] | None: def load(self) -> dict[str, str] | None:
"""Charge les credentials d'authentification depuis le fichier d'état. """Charge les credentials d'authentification depuis le fichier d'état.
@@ -65,6 +77,10 @@ class PronoteAuthState:
aucun état valide n'est disponible. aucun état valide n'est disponible.
:rtype: dict[str, str] | None :rtype: dict[str, str] | None
""" """
if self._in_memory_state_cleared:
return None
if self._in_memory_credentials is not None:
return self._in_memory_credentials.copy()
if not self._state_file.exists(): if not self._state_file.exists():
logger.debug( logger.debug(
"Fichier d'état d'authentification Pronote %s absent, aucun token à charger.", "Fichier d'état d'authentification Pronote %s absent, aucun token à charger.",
@@ -126,6 +142,10 @@ class PronoteAuthState:
:raises PronoteSyncError: Si l'écriture ou le remplacement du fichier :raises PronoteSyncError: Si l'écriture ou le remplacement du fichier
échoue. échoue.
""" """
if not self._persistence_enabled:
self._in_memory_credentials = credentials.copy()
self._in_memory_state_cleared = False
return
payload: dict[str, Any] = { payload: dict[str, Any] = {
"version": _STATE_VERSION, "version": _STATE_VERSION,
"credentials": credentials, "credentials": credentials,
@@ -182,10 +202,16 @@ class PronoteAuthState:
"""Supprime le fichier d'état d'authentification. """Supprime le fichier d'état d'authentification.
Si le fichier n'existe pas, la méthode ne fait rien et aucune erreur Si le fichier n'existe pas, la méthode ne fait rien et aucune erreur
n'est levée. n'est levée. Lorsque la persistance est désactivée, elle efface
uniquement les credentials conservés en mémoire et ne modifie jamais
le fichier d'état.
:raises OSError: Si la suppression du fichier existant échoue. :raises OSError: Si la suppression du fichier existant échoue.
""" """
self._in_memory_credentials = None
if not self._persistence_enabled:
self._in_memory_state_cleared = True
return
if not self._state_file.exists(): if not self._state_file.exists():
return return
logger.debug( logger.debug(

View File

@@ -35,6 +35,49 @@ def test_state_file_absent_empty_state(tmp_path: Path) -> None:
assert state.get_cache_headers() == (None, None) assert state.get_cache_headers() == (None, None)
def test_disabled_persistence_keeps_updates_in_memory_without_creating_file(tmp_path: Path) -> None:
"""Vérifie que la persistance désactivée conserve l'état uniquement en mémoire.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
state = BlogRSSState(state_file, persistence_enabled=False)
state.add_guids(["guid-1"])
state.update_cache_headers("etag-123", "Wed, 01 Sep 2026 GMT")
assert state.get_known_guids() == frozenset({"guid-1"})
assert state.get_cache_headers() == ("etag-123", "Wed, 01 Sep 2026 GMT")
assert not state_file.exists()
def test_disabled_persistence_preserves_existing_file(tmp_path: Path) -> None:
"""Vérifie que la persistance désactivée ne modifie pas l'état déjà stocké.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
original_content = json.dumps(
{
"version": 1,
"known_guids": ["existing-guid"],
"etag": "old-etag",
"last_modified": "Tue, 31 Aug 2026 GMT",
}
)
state_file.write_text(original_content, encoding="utf-8")
state = BlogRSSState(state_file, persistence_enabled=False)
state.add_guids(["new-guid"])
state.update_cache_headers("new-etag", "Wed, 01 Sep 2026 GMT")
assert state.get_known_guids() == frozenset({"existing-guid", "new-guid"})
assert state.get_cache_headers() == ("new-etag", "Wed, 01 Sep 2026 GMT")
assert state_file.read_text(encoding="utf-8") == original_content
def test_add_guids_persists(tmp_path: Path) -> None: def test_add_guids_persists(tmp_path: Path) -> None:
"""Vérifie que l'ajout de GUID persiste dans le fichier JSON. """Vérifie que l'ajout de GUID persiste dans le fichier JSON.

View File

@@ -35,6 +35,90 @@ def test_load_no_file_returns_none(tmp_path: Path) -> None:
assert state.load() is None assert state.load() is None
def test_disabled_persistence_keeps_credentials_in_memory_without_creating_file(
tmp_path: Path,
) -> None:
"""Vérifie que la persistance désactivée conserve les credentials en mémoire.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "auth.json"
credentials = {
"pronote_url": "https://example.com/pronote",
"username": "parent-1",
"password": "token-123", # pragma: allowlist secret
"uuid": "uuid-456",
}
state = PronoteAuthState(state_file, persistence_enabled=False)
state.save(credentials)
assert state.load() == credentials
assert not state_file.exists()
def test_disabled_persistence_preserves_existing_file(tmp_path: Path) -> None:
"""Vérifie que la persistance désactivée garde les nouveaux credentials en mémoire.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "auth.json"
original_credentials = {
"pronote_url": "https://example.com/pronote",
"username": "parent-1",
"password": "old-token", # pragma: allowlist secret
"uuid": "old-uuid",
}
original_content = json.dumps({"version": 1, "credentials": original_credentials}).encode()
new_credentials = {
"pronote_url": "https://example.com/pronote",
"username": "parent-1",
"password": "new-token", # pragma: allowlist secret
"uuid": "new-uuid",
}
state_file.write_bytes(original_content)
state = PronoteAuthState(state_file, persistence_enabled=False)
assert state.load() == original_credentials
state.save(new_credentials)
assert state.load() == new_credentials
assert state_file.read_bytes() == original_content
def test_disabled_persistence_clear_discards_in_memory_credentials_only(tmp_path: Path) -> None:
"""Vérifie que clear oublie l'état en mémoire sans modifier le fichier existant.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "auth.json"
original_credentials = {
"pronote_url": "https://example.com/pronote",
"username": "parent-1",
"password": "old-token", # pragma: allowlist secret
"uuid": "old-uuid",
}
original_content = json.dumps({"version": 1, "credentials": original_credentials}).encode()
credentials = {
"pronote_url": "https://example.com/pronote",
"username": "parent-1",
"password": "new-token", # pragma: allowlist secret
"uuid": "new-uuid",
}
state_file.write_bytes(original_content)
state = PronoteAuthState(state_file, persistence_enabled=False)
state.save(credentials)
state.clear()
assert state.load() is None
assert state_file.read_bytes() == original_content
def test_save_then_load_roundtrip(tmp_path: Path) -> None: def test_save_then_load_roundtrip(tmp_path: Path) -> None:
"""Vérifie que des credentials sauvegardés sont rechargés à l'identique. """Vérifie que des credentials sauvegardés sont rechargés à l'identique.