Compare commits

..

1 Commits

Author SHA1 Message Date
fa87832125 release: v0.1.2 — authentification QR code / token Pronote
Bump version 0.1.0 → 0.1.2.
CHANGELOG mis à jour avec les ajouts, corrections et issues connues.
2026-09-10 14:01:25 +02:00
6 changed files with 6 additions and 282 deletions

View File

@@ -126,9 +126,6 @@ class PipelineRunner:
:rtype: PipelineRunner :rtype: PipelineRunner
""" """
effective_dry_run = settings.app.dry_run if dry_run is None else dry_run effective_dry_run = settings.app.dry_run if dry_run is None else dry_run
if effective_dry_run and settings.pronote.auth_mode == "qr_token":
raise ValueError("Le mode qr_token n'est pas compatible avec le dry-run.")
persistence_enabled = not effective_dry_run
theoretical_provider = get_theoretical_provider( theoretical_provider = get_theoretical_provider(
settings.app.theoretical_agenda_path, settings.app.theoretical_agenda_path,
settings.app.school_holidays_path, settings.app.school_holidays_path,
@@ -139,9 +136,7 @@ class PipelineRunner:
AgendaComparator(theoretical_provider) if theoretical_provider is not None else None AgendaComparator(theoretical_provider) if theoretical_provider is not None else None
) )
blog_client = BlogRSSClient(settings.blog.rss_url) if settings.blog.enabled else None blog_client = BlogRSSClient(settings.blog.rss_url) if settings.blog.enabled else None
blog_state = ( blog_state = BlogRSSState() if settings.blog.enabled else None
BlogRSSState(persistence_enabled=persistence_enabled) if settings.blog.enabled else None
)
return cls( return cls(
settings=settings, settings=settings,
pronote_fetcher=PronoteFetcher( pronote_fetcher=PronoteFetcher(
@@ -149,9 +144,7 @@ class PipelineRunner:
PronoteClient( PronoteClient(
settings.pronote, settings.pronote,
auth_state=( auth_state=(
PronoteAuthState(persistence_enabled=persistence_enabled) PronoteAuthState() if settings.pronote.auth_mode == "qr_token" else None
if settings.pronote.auth_mode == "qr_token"
else None
), ),
), ),
), ),

View File

@@ -37,24 +37,15 @@ 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__( def __init__(self, state_file: Path | str = ".blog_rss_state.json") -> None:
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
@@ -107,8 +98,6 @@ 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,15 +38,9 @@ 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__( def __init__(self, state_file: Path | str = ".pronote_auth_state.json") -> None:
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
@@ -54,14 +48,8 @@ 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.
@@ -77,10 +65,6 @@ 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.",
@@ -142,10 +126,6 @@ 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,
@@ -202,16 +182,10 @@ 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. Lorsque la persistance est désactivée, elle efface n'est levée.
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

@@ -8,13 +8,7 @@ from typing import Any, cast
import pytest import pytest
from pydantic import SecretStr from pydantic import SecretStr
from pronote_sync.config.settings import ( from pronote_sync.config.settings import AISettings, AppSettings, PronoteSettings, Settings
AISettings,
AppSettings,
BlogSettings,
PronoteSettings,
Settings,
)
from pronote_sync.errors import PipelineCriticalError, PipelineWarning, PronoteAuthRotationError from pronote_sync.errors import PipelineCriticalError, PipelineWarning, PronoteAuthRotationError
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent
from pronote_sync.models.blog import BlogArticle from pronote_sync.models.blog import BlogArticle
@@ -428,47 +422,6 @@ def test_from_settings_password_mode_passes_auth_state_none(
assert constructed[0][1] is None assert constructed[0][1] is None
def test_from_settings_rejects_qr_token_dry_run_before_constructing_pronote_client(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""QR-token dry-run fails before authentication or data dependencies are created."""
import pronote_sync.pipeline.run as run_module
qr_pin_sentinel = "qr-pin-must-not-appear"
constructed: list[object] = []
class FailingClient:
"""Pronote client sentinel that makes unexpected construction explicit."""
def __init__(self, settings: PronoteSettings, *, auth_state: object) -> None:
"""Record and reject any unexpected client construction.
:param settings: Pronote settings supplied by the composition root.
:param auth_state: Authentication state supplied by the composition root.
"""
del settings, auth_state
constructed.append(object())
raise AssertionError("PronoteClient must not be constructed for QR-token dry-run")
monkeypatch.setattr(run_module, "PronoteClient", FailingClient)
with pytest.raises(ValueError) as exc_info:
PipelineRunner.from_settings(
Settings(
app=AppSettings(dry_run=True),
pronote=PronoteSettings(
auth_mode="qr_token",
qr_pin=SecretStr(qr_pin_sentinel),
),
)
)
assert "qr_token" in str(exc_info.value)
assert "dry-run" in str(exc_info.value)
assert qr_pin_sentinel not in str(exc_info.value)
assert constructed == []
def test_from_settings_qr_token_mode_passes_auth_state_instance( def test_from_settings_qr_token_mode_passes_auth_state_instance(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
@@ -496,64 +449,6 @@ def test_from_settings_qr_token_mode_passes_auth_state_instance(
assert isinstance(constructed[0][1], PronoteAuthState) assert isinstance(constructed[0][1], PronoteAuthState)
@pytest.mark.parametrize("dry_run", [False, True])
def test_from_settings_configures_source_state_persistence_for_dry_run(
monkeypatch: pytest.MonkeyPatch,
dry_run: bool,
) -> None:
"""Composition disables source-state persistence only in dry-run mode."""
import pronote_sync.pipeline.run as run_module
blog_persistence: list[bool] = []
auth_persistence: list[bool] = []
class RecordingBlogState:
"""Blog state factory recording its persistence configuration."""
def __init__(self, *, persistence_enabled: bool = True) -> None:
"""Record the requested persistence setting.
:param persistence_enabled: Whether disk writes are enabled.
"""
blog_persistence.append(persistence_enabled)
class RecordingAuthState:
"""Authentication state factory recording its persistence configuration."""
def __init__(self, *, persistence_enabled: bool = True) -> None:
"""Record the requested persistence setting.
:param persistence_enabled: Whether disk writes are enabled.
"""
auth_persistence.append(persistence_enabled)
class RecordingClient:
"""Pronote client constructor accepting the injected auth state."""
def __init__(self, settings: PronoteSettings, *, auth_state: object) -> None:
"""Accept the composition-root dependencies.
:param settings: Pronote settings.
:param auth_state: Injected authentication state.
"""
del settings, auth_state
monkeypatch.setattr(run_module, "BlogRSSState", RecordingBlogState)
monkeypatch.setattr(run_module, "PronoteAuthState", RecordingAuthState)
monkeypatch.setattr(run_module, "PronoteClient", RecordingClient)
PipelineRunner.from_settings(
Settings(
app=AppSettings(dry_run=dry_run),
blog=BlogSettings(enabled=True),
pronote=PronoteSettings(auth_mode="qr_token"),
)
)
assert blog_persistence == [not dry_run]
assert auth_persistence == [not dry_run]
def test_runner_reuses_ical_download_and_parse_within_one_run( def test_runner_reuses_ical_download_and_parse_within_one_run(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
pipeline_inputs: tuple[Lesson, Homework], pipeline_inputs: tuple[Lesson, Homework],

View File

@@ -35,49 +35,6 @@ 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,90 +35,6 @@ 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.