Merge remote-tracking branch 'origin/main' into feat/issue-8-account-pin

# Conflicts:
#	docs/exploitation.md
This commit is contained in:
2026-09-12 23:57:06 +02:00
11 changed files with 348 additions and 40 deletions
+18 -4
View File
@@ -71,6 +71,13 @@ exporté depuis le site web Pronote. Si le compte exige un second facteur,
configurez aussi `PRONOTE_ACCOUNT_PIN` avec le PIN du compte. Ce PIN est
transmis uniquement à `pronotepy` lors de l'enrôlement QR et des connexions par
token ; il n'est jamais écrit dans `.pronote_auth_state.json` ni dans les logs.
Si le blog RSS est activé, ses GUID ne sont acquittés qu'après confirmation de
l'envoi XMPP. Un refus, une exception, l'absence de canal ou un `--dry-run`
laisse donc les articles récupérables à l'exécution suivante ; les en-têtes
HTTP associés à ces articles suivent la même règle pour éviter un `304` qui
masquerait une livraison non confirmée.
En mode `PRONOTE_AUTH_MODE=qr_token`, le fichier
`.pronote_auth_state.json` et son verrou frère sont créés dans le répertoire
de travail du service (par exemple `/var/lib/pronote-sync`) avec le mode
@@ -110,10 +117,17 @@ sudo systemctl start pronote-sync.service
sudo systemctl status pronote-sync.service
```
Une exécution en échec laisse l'unité `pronote-sync.service` en état `failed`.
La supervision de l'hôte doit donc déclencher une alerte sur cet état ou sur un
échec du timer/service ; le transport de cette alerte (courriel, XMPP ou système
de supervision) relève de l'exploitation locale.
La CLI expose un contrat de sortie stable : `0` signifie une exécution complète,
`2` une exécution dégradée (les données Pronote sont disponibles mais une étape
optionnelle, CalDAV ou XMPP a échoué), et `1` un échec critique. Tout code non
nul laisse l'unité `pronote-sync.service` en état `failed` ; la supervision doit
donc alerter sur cet état ou sur le code de sortie. Le code `2` permet de
distinguer automatiquement une alerte dégradée d'une panne critique, sans lire
les journaux.
Le `--dry-run` n'écrit ni dans CalDAV/XMPP ni dans l'état local. Il conserve le
même contrat de codes : `0` si la simulation est complète, `2` si elle est
dégradée et `1` si elle est critique.
## Journaux et alertes
+23 -4
View File
@@ -11,6 +11,7 @@ from pydantic import SecretStr
from pronote_sync.config.env import load_settings
from pronote_sync.config.settings import Settings
from pronote_sync.errors import ErrorSeverity, PipelineError
from pronote_sync.pipeline.run import PipelineRunner
from pronote_sync.utils.logging import setup_logging
from pronote_sync.utils.redaction import redact_secrets
@@ -19,6 +20,26 @@ logger = logging.getLogger(__name__)
_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
# Contrat stable pour systemd et les outils de supervision.
EXIT_SUCCESS = 0
EXIT_CRITICAL = 1
EXIT_DEGRADED = 2
def _pipeline_exit_code(data: object | None, errors: Sequence[PipelineError]) -> int:
"""Convertit le résultat du pipeline en code de sortie supervisable.
:param data: Données normalisées produites, ou ``None`` en cas d'échec critique.
:param errors: Erreurs et avertissements de l'exécution.
:return: ``0`` si complet, ``2`` si dégradé, ``1`` si critique.
:rtype: int
"""
if data is None or any(error.severity == ErrorSeverity.CRITICAL for error in errors):
return EXIT_CRITICAL
if errors:
return EXIT_DEGRADED
return EXIT_SUCCESS
def _parse_arguments(arguments: Sequence[str] | None = None) -> argparse.Namespace:
"""Analyse les options de lancement du programme.
@@ -120,7 +141,7 @@ def main(arguments: Sequence[str] | None = None) -> int:
bruts afin de préserver le diagnostic sans exposer de secret.
:param arguments: Arguments optionnels, principalement utiles aux appels programmatiques.
:return: ``0`` en cas de succès, ``1`` sinon (après analyse des arguments).
:return: Code machine-readable : ``0`` complet, ``2`` dégradé, ``1`` critique.
:rtype: int
:raises SystemExit: Si argparse rejette les arguments (code de sortie 2).
"""
@@ -147,9 +168,7 @@ def main(arguments: Sequence[str] | None = None) -> int:
secrets = _settings_secrets(settings)
for error in errors:
logger.error("%s", redact_secrets(error.message, extra_secrets=secrets))
if data is None:
return 1
return 0
return _pipeline_exit_code(data, errors)
if __name__ == "__main__":
+7 -2
View File
@@ -230,11 +230,13 @@ class PipelineRunner:
data = normalize_step(fetched, generated_at=now)
try:
blog_articles = fetch_blog_step(self._blog_client, self._blog_state)
blog_result = fetch_blog_step(self._blog_client, self._blog_state)
blog_articles = list(blog_result.articles)
except PipelineCriticalError:
raise
except Exception as exc:
self._warn("fetch_blog", self._redact(exc))
blog_result = None
blog_articles = []
try:
@@ -290,8 +292,11 @@ class PipelineRunner:
)
if self._channel is not None and not self._dry_run:
try:
if not send_step(self._channel, message):
delivered = send_step(self._channel, message)
if not delivered:
self._warn("send", "Le canal XMPP a refusé l'envoi")
elif blog_result is not None and self._blog_state is not None:
self._blog_state.acknowledge(blog_result)
except PipelineCriticalError:
raise
except Exception as exc:
+13 -9
View File
@@ -2,23 +2,28 @@
from __future__ import annotations
from pronote_sync.models.blog import BlogArticle
from pronote_sync.sources.blog.result import BlogRSSFetchResult
from pronote_sync.sources.blog.rss import BlogRSSClient
from pronote_sync.sources.blog.state import BlogRSSState
from pronote_sync.utils.redaction import redact_exception
def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) -> list[BlogArticle]:
"""Récupère les articles RSS nouveaux en conservant l'état du client.
def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) -> BlogRSSFetchResult:
"""Récupère les articles RSS nouveaux sans les acquitter.
L'état des GUID est acquitté séparément par le pipeline après confirmation
de la livraison XMPP. Les en-têtes de cache d'une réponse sans article
peuvent être conservés immédiatement, car aucune livraison n'est alors en
attente.
:param client: Client RSS configuré, ou ``None`` lorsque le blog est désactivé.
:param state: État de déduplication et de cache HTTP associé au run.
:return: Nouveaux articles du blog.
:rtype: list[BlogArticle]
:return: Résultat de récupération, incluant les métadonnées de cache.
:rtype: BlogRSSFetchResult
:raises RuntimeError: Si la récupération RSS injectée échoue.
"""
if client is None or state is None:
return []
return BlogRSSFetchResult()
try:
etag, last_modified = state.get_cache_headers()
result = client.fetch_and_parse(
@@ -26,9 +31,8 @@ def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) ->
)
if result.error is not None:
raise RuntimeError(result.error) from None
if not result.not_modified:
state.add_guids(article.id for article in result.articles)
if not result.not_modified and not result.articles:
state.update_cache_headers(result.etag, result.last_modified)
return list(result.articles)
return result
except Exception as exc:
raise RuntimeError(f"Récupération du blog échouée : {redact_exception(exc)}") from None
+17
View File
@@ -19,6 +19,7 @@ import logging
from collections.abc import Iterable
from pathlib import Path
from pronote_sync.sources.blog.result import BlogRSSFetchResult
from pronote_sync.utils.redaction import redact_exception, redact_secrets
logger = logging.getLogger(__name__)
@@ -156,6 +157,22 @@ class BlogRSSState:
self._known_guids.update(new_guids)
self._save()
def acknowledge(self, result: BlogRSSFetchResult) -> None:
"""Acquitte une récupération RSS après sa livraison confirmée.
Les GUID et les en-têtes de cache sont enregistrés ensemble afin qu'un
article dont la livraison a échoué reste récupérable à l'exécution
suivante. Une réponse ``304 Not Modified`` n'a rien à acquitter.
:param result: Résultat RSS livré avec succès.
"""
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()
def get_cache_headers(self) -> tuple[str | None, str | None]:
"""Renvoie les en-têtes de cache HTTP mémorisés.
+16 -7
View File
@@ -14,7 +14,7 @@ l'outil et de ne jamais toucher aux événements étrangers du calendrier.
from __future__ import annotations
from datetime import datetime, time
from datetime import datetime, timedelta
from typing import cast
from icalendar import Calendar, Component, Event, vDate, vDatetime
@@ -33,7 +33,15 @@ MANAGED_VALUE = "v1"
PRODID = "-//pronote-sync//NONSGML v1.0//EN"
#: Propriétés prises en compte dans la signature sémantique d'un composant.
_SIGNATURE_KEYS: tuple[str, ...] = ("UID", "SUMMARY", "DTSTART", "DTEND", "STATUS", "DESCRIPTION")
_SIGNATURE_KEYS: tuple[str, ...] = (
"UID",
"SUMMARY",
"DTSTART",
"DTEND",
"STATUS",
"TRANSP",
"DESCRIPTION",
)
def lesson_to_vevent(lesson: Lesson) -> Event:
@@ -88,8 +96,9 @@ def lesson_to_vevent(lesson: Lesson) -> Event:
def homework_to_vevent(homework: Homework) -> Event:
"""Convertit un devoir Pronote en composant VEVENT iCalendar.
Le devoir est représenté comme une tâche (``STATUS:NEEDS-ACTION``) sur la
journée d'échéance, entre 08:00 et 18:00.
Le devoir est représenté comme un événement toute la journée à la date
d'échéance. Il est transparent pour ne pas bloquer les disponibilités ;
aucun statut de tâche ``VTODO`` n'est ajouté à ce ``VEVENT``.
:param homework: Devoir Pronote à sérialiser.
:return: Composant :class:`icalendar.Event` marqué comme géré par l'outil.
@@ -98,10 +107,10 @@ def homework_to_vevent(homework: Homework) -> Event:
event = Event()
event.add("uid", f"homework-{homework.id}")
event.add("summary", f"Devoir: {homework.subject}")
event.add("dtstart", vDatetime(datetime.combine(homework.due_on, time(8, 0))))
event.add("dtend", vDatetime(datetime.combine(homework.due_on, time(18, 0))))
event.add("dtstart", vDate(homework.due_on))
event.add("dtend", vDate(homework.due_on + timedelta(days=1)))
event.add("description", homework.text)
event.add("status", "NEEDS-ACTION")
event.add("transp", "TRANSPARENT")
event.add("categories", ["Pronote", "Devoir"])
event.add(MANAGED_PROPERTY, MANAGED_VALUE)
return event
+23 -3
View File
@@ -52,10 +52,10 @@ def test_main_runs_composition_root_in_dry_run_with_requested_log_level(
runner.run.assert_called_once_with()
def test_main_preserves_configured_dry_run_and_returns_success_with_warnings(
def test_main_preserves_configured_dry_run_and_returns_degraded_with_warnings(
mocker: MockerFixture,
) -> None:
"""Sans option, la CLI préserve le dry-run configuré et accepte les avertissements."""
"""Sans option, la CLI préserve le dry-run configuré et signale l'état dégradé."""
from pronote_sync.cli.main import main
settings = Settings(app=AppSettings(dry_run=True, log_level="WARNING"))
@@ -72,12 +72,32 @@ def test_main_preserves_configured_dry_run_and_returns_success_with_warnings(
exit_code = main([])
assert exit_code == 0
assert exit_code == 2
assert setup_logging.call_args_list == [mocker.call("INFO"), mocker.call("WARNING")]
composition_root.assert_called_once_with(settings, dry_run=None)
runner.run.assert_called_once_with()
@pytest.mark.parametrize("step", ["caldav_sync", "send"])
def test_main_returns_degraded_code_for_caldav_or_xmpp_failure(
mocker: MockerFixture,
step: str,
) -> None:
"""Les échecs récupérables CalDAV et XMPP sont observables par le code 2."""
from pronote_sync.cli.main import main
settings = Settings()
mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings)
runner = mocker.Mock()
runner.run.return_value = (
mocker.Mock(spec=PronoteData),
[PipelineWarning(f"Échec récupérable de {step}", step=step)],
)
mocker.patch("pronote_sync.cli.main.PipelineRunner.from_settings", return_value=runner)
assert main([]) == 2
def test_main_returns_failure_and_redacts_pipeline_secrets_at_debug_level(
mocker: MockerFixture,
capsys: pytest.CaptureFixture[str],
+81 -1
View File
@@ -7,7 +7,7 @@ ajouts/mises à jour/suppressions, et la préservation des événements non gér
from __future__ import annotations
from datetime import datetime
from datetime import date, datetime
from typing import TYPE_CHECKING, Any
import pytest
@@ -122,10 +122,16 @@ class FakeCalendar:
event_start = raw_start.dt
event_end = raw_end.dt if raw_end is not None else event_start
overlaps = True
if isinstance(event_start, datetime):
if start is not None:
overlaps = overlaps and event_end > start
if end is not None:
overlaps = overlaps and event_start < end
else:
if start is not None:
overlaps = overlaps and event_end > start.date()
if end is not None:
overlaps = overlaps and event_start < end.date()
if overlaps:
results.append(FakeCalendarEvent(ical_text, uid=uid, server=self._server))
break
@@ -686,6 +692,80 @@ class TestCalDAVSynchronize:
assert result.added == 1
assert len(fake_caldav_server.get_events()) == 1
def test_legacy_homework_vevent_is_migrated_idempotently_and_removed(
self,
fake_caldav_server: FakeCalDAVServer,
full_settings: Settings,
) -> None:
"""Migre un ancien devoir puis vérifie l'idempotence et la suppression."""
fake_caldav_server._events["homework-hw-legacy"] = _create_vevent_text(
uid="homework-hw-legacy",
summary="Devoir: Histoire",
start=datetime(2026, 1, 20, 8, 0),
end=datetime(2026, 1, 20, 18, 0),
status="NEEDS-ACTION",
managed=True,
)
homework = Homework(
id="hw-legacy",
subject="Histoire",
assigned_on=None,
due_on=date(2026, 1, 20),
text="Lire le chapitre 5",
)
def make_data(homeworks: list[Homework]) -> PronoteData:
"""Construit les données de synchronisation du scénario."""
return PronoteData(
lessons=[],
homeworks=homeworks,
school_events=[],
messages=[],
target_date=date(2026, 1, 20),
generated_at=datetime(2026, 1, 14, 0, 0),
)
first = synchronize(
pronote_data=make_data([homework]),
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
assert first.status == CalDAVSyncStatus.SUCCESS
assert first.added == 0
assert first.updated == 1
assert first.removed == 0
migrated = Calendar.from_ical(fake_caldav_server.get_events()["homework-hw-legacy"])
event = migrated.walk("VEVENT")[0]
assert event.get("DTSTART").dt == date(2026, 1, 20)
assert event.get("DTEND").dt == date(2026, 1, 21)
assert event.get("STATUS") is None
assert str(event.get("TRANSP")) == "TRANSPARENT"
second = synchronize(
pronote_data=make_data([homework]),
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
assert second.status == CalDAVSyncStatus.SKIPPED
assert second.added == 0
assert second.updated == 0
assert second.removed == 0
removed = synchronize(
pronote_data=make_data([]),
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
assert removed.status == CalDAVSyncStatus.SUCCESS
assert removed.added == 0
assert removed.updated == 0
assert removed.removed == 1
assert fake_caldav_server.get_events() == {}
def test_school_event_sync(
self,
fake_caldav_server: FakeCalDAVServer,
+104
View File
@@ -1040,6 +1040,110 @@ def test_runner_blog_success_delivers_articles_into_xmpp_message_external_info(
assert xmpp_message.external_info.blog_articles[0].title == "Test Article"
@pytest.mark.parametrize(
("channel_kind", "dry_run", "should_acknowledge"),
[
("success", False, True),
("false", False, False),
("exception", False, False),
("none", False, False),
("success", True, False),
],
)
def test_runner_acknowledges_blog_only_after_confirmed_xmpp_delivery(
pipeline_inputs: tuple[Lesson, Homework],
tmp_path: Any,
channel_kind: str,
dry_run: bool,
should_acknowledge: bool,
) -> None:
"""Les GUID RSS restent rejouables tant que XMPP n'a pas confirmé l'envoi.
:param pipeline_inputs: Données Pronote de test.
:param tmp_path: Répertoire temporaire pour l'état RSS.
:param channel_kind: Comportement du canal XMPP simulé.
:param dry_run: Active ou non le mode simulation.
:param should_acknowledge: Indique si l'état RSS doit être acquitté.
"""
lesson, homework = pipeline_inputs
calls: list[str] = []
state_file = tmp_path / "blog-state.json"
class SuccessfulBlogClient:
"""Client RSS renvoyant un article non encore livré."""
def fetch_and_parse(
self,
*,
known_guids: frozenset[str] | None = None,
etag: str | None = None,
last_modified: str | None = None,
) -> BlogRSSFetchResult:
"""Retourne un article et des en-têtes de cache déterministes.
:param known_guids: GUID déjà connus, ignorés dans ce faux client.
:param etag: ETag mémorisé, ignoré dans ce faux client.
:param last_modified: Date HTTP mémorisée, ignorée dans ce faux client.
:return: Résultat RSS avec un article à livrer.
:rtype: BlogRSSFetchResult
"""
del known_guids, etag, last_modified
return BlogRSSFetchResult(
articles=(
BlogArticle(
id="article-to-deliver",
title="Article à livrer",
url="https://example.com/article-to-deliver",
published_at=datetime(2026, 9, 8, 12, 0),
updated_at=None,
category=None,
author=None,
content_html="<p>Contenu</p>",
content_text="Contenu",
),
),
etag="etag-after-delivery",
last_modified="Tue, 08 Sep 2026 12:00:00 GMT",
)
channel: Any
if channel_kind == "success":
channel = StubChannel(calls)
elif channel_kind == "false":
channel = FailingChannel()
elif channel_kind == "exception":
channel = ExceptionalChannel()
else:
channel = None
runner = PipelineRunner(
settings=Settings(blog=Settings().blog.model_copy(update={"enabled": True})),
pronote_fetcher=StubFetcher(calls, lesson, homework),
caldav_synchronizer=lambda data, settings: successful_sync_result(),
agenda_comparator=cast("AgendaComparator | None", StubComparator(calls)),
blog_client=cast("BlogRSSClient | None", SuccessfulBlogClient()),
blog_state=BlogRSSState(state_file),
channel=channel,
dry_run=dry_run,
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is not None
if should_acknowledge:
acknowledged_state = BlogRSSState(state_file)
assert acknowledged_state.get_known_guids() == frozenset({"article-to-deliver"})
assert acknowledged_state.get_cache_headers() == (
"etag-after-delivery",
"Tue, 08 Sep 2026 12:00:00 GMT",
)
else:
assert not state_file.exists()
if channel_kind in {"false", "exception"}:
assert any(error.step == "send" for error in errors)
def test_runner_secret_redaction_in_pipeline_errors(
pipeline_inputs: tuple[Lesson, Homework],
) -> None:
+35
View File
@@ -14,11 +14,14 @@ Tous les tests utilisent des fichiers temporaires via la fixture ``tmp_path``.
from __future__ import annotations
import json
from datetime import UTC, datetime
from pathlib import Path
from unittest.mock import patch
import pytest
from pronote_sync.models.blog import BlogArticle
from pronote_sync.sources.blog.result import BlogRSSFetchResult
from pronote_sync.sources.blog.state import BlogRSSState
@@ -116,6 +119,38 @@ def test_add_guids_empty_noop(tmp_path: Path) -> None:
assert state_file.read_text(encoding="utf-8") == original_content
def test_acknowledge_persists_guids_and_cache_headers_together(tmp_path: Path) -> None:
"""Vérifie l'acquittement atomique après une livraison confirmée.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
state = BlogRSSState(state_file)
article = BlogArticle(
id="guid-1",
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",
)
state.acknowledge(
BlogRSSFetchResult(
articles=(article,),
etag="etag-1",
last_modified="Sat, 12 Sep 2026 08:00:00 GMT",
)
)
assert state.get_known_guids() == frozenset({"guid-1"})
assert state.get_cache_headers() == ("etag-1", "Sat, 12 Sep 2026 08:00:00 GMT")
def test_state_load_persisted_guids(tmp_path: Path) -> None:
"""Vérifie que les GUID persistés sont rechargés dans une nouvelle instance.
+7 -6
View File
@@ -239,15 +239,16 @@ def test_homework_to_vevent_uid_prefix() -> None:
assert str(event.get("UID")) == "homework-HW-5678"
def test_homework_to_vevent_status() -> None:
"""Vérifie qu'un devoir a STATUS=NEEDS-ACTION.
def test_homework_to_vevent_is_transparent_without_task_status() -> None:
"""Vérifie qu'un devoir VEVENT est transparent et sans statut VTODO.
:return: None
"""
homework = _make_homework()
event = homework_to_vevent(homework)
assert str(event.get("STATUS")) == "NEEDS-ACTION"
assert event.get("STATUS") is None
assert str(event.get("TRANSP")) == "TRANSPARENT"
def test_homework_to_vevent_categories() -> None:
@@ -265,15 +266,15 @@ def test_homework_to_vevent_categories() -> None:
def test_homework_to_vevent_dtstart_dtend() -> None:
"""Vérifie que DTSTART et DTEND couvrent la journée d'échéance (08:00-18:00).
"""Vérifie que DTSTART et DTEND encadrent la journée d'échéance.
:return: None
"""
homework = _make_homework(due_on=date(2026, 1, 20))
event = homework_to_vevent(homework)
assert event.get("DTSTART").dt == datetime(2026, 1, 20, 8, 0)
assert event.get("DTEND").dt == datetime(2026, 1, 20, 18, 0)
assert event.get("DTSTART").dt == date(2026, 1, 20)
assert event.get("DTEND").dt == date(2026, 1, 21)
def test_homework_to_vevent_summary() -> None: