fix(dry-run) : garantir une simulation sans persistance #24
@@ -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),
|
||||||
|
|||||||
@@ -41,9 +41,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
|
||||||
@@ -51,8 +57,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.
|
||||||
@@ -68,6 +80,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.",
|
||||||
@@ -192,6 +208,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,
|
||||||
@@ -248,10 +268,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(
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user