Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c6a0e3f29 |
+1
-1
@@ -32,7 +32,7 @@ CALDAV_CALENDAR_PATH=/pronote-sync/
|
||||
# Autoriser HTTP (non-HTTPS) pour un serveur CalDAV local (localhost uniquement)
|
||||
CALDAV_ALLOW_INSECURE_HTTP=false
|
||||
|
||||
# Fenêtre de synchronisation (jours)
|
||||
# Fenêtre de synchronisation (jours) — entier >= 0 ; 0 = aucune journée supplémentaire de ce côté (le jour courant reste inclus). Valeurs négatives refusées au chargement (ValidationError).
|
||||
SYNC_PAST_DAYS=7
|
||||
SYNC_FUTURE_DAYS=30
|
||||
|
||||
|
||||
+4
-4
@@ -299,8 +299,8 @@ d'un besoin réel et testé.
|
||||
| `PRONOTE_AGENDA_SOURCE` | Source pour l'agenda (`auto`, `ical`, `pronotepy`). | `auto` | `Literal` |
|
||||
| `PRONOTE_HOMEWORK_SOURCE` | Source pour les devoirs (`auto`, `ical`, `pronotepy`). | `auto` | `Literal` |
|
||||
| `PRONOTE_MESSAGES_SOURCE` | Source pour les messages (`pronotepy` uniquement). | `pronotepy` | `Literal` |
|
||||
| `SYNC_PAST_DAYS` | Nombre de jours dans le passé pour la sync CalDAV. | `7` | `int` |
|
||||
| `SYNC_FUTURE_DAYS` | Nombre de jours dans le futur pour la sync CalDAV. | `30` | `int` |
|
||||
| `SYNC_PAST_DAYS` | Nombre de jours dans le passé pour la sync CalDAV, entier `>= 0` (`0` = aucune journée supplémentaire de ce côté ; le jour courant reste inclus). | `7` | `int` |
|
||||
| `SYNC_FUTURE_DAYS` | Nombre de jours dans le futur pour la sync CalDAV, entier `>= 0` (`0` = aucune journée supplémentaire de ce côté ; le jour courant reste inclus). | `30` | `int` |
|
||||
| `THEORETICAL_AGENDA_PATH` | Chemin vers le fichier JSON de l'agenda théorique. | `None` | `str \| None`|
|
||||
| `SCHOOL_HOLIDAYS_PATH` | Chemin vers le fichier JSON des vacances scolaires. | `None` | `str \| None`|
|
||||
| `THEORETICAL_WEEK_ANCHOR_DATE` | Date de référence pour la parité des semaines (paire/impaire). | `None` | `date \| None`|
|
||||
@@ -421,8 +421,8 @@ class CalDAVSettings(BaseSettings):
|
||||
password: SecretStr | None = None
|
||||
calendar_path: str = "/pronote-sync/"
|
||||
allow_insecure_http: bool = False
|
||||
sync_past_days: int = 7
|
||||
sync_future_days: int = 30
|
||||
sync_past_days: int = Field(default=7, ge=0)
|
||||
sync_future_days: int = Field(default=30, ge=0)
|
||||
|
||||
|
||||
class AISettings(BaseSettings):
|
||||
|
||||
@@ -66,12 +66,6 @@ processus. Le mode `PRONOTE_AUTH_MODE=qr_token` est incompatible avec cette gara
|
||||
le refuse avant toute connexion afin de ne pas désynchroniser le token local du token distant.
|
||||
Le dry-run ne remplace pas une vérification des paramètres réellement chargés.
|
||||
|
||||
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
|
||||
|
||||
@@ -293,8 +293,8 @@ class AppSettings(BaseSettings):
|
||||
school_holidays_path: str | None = None
|
||||
theoretical_week_anchor_date: date | None = None
|
||||
theoretical_week_anchor_type: Literal["even", "odd"] | None = None
|
||||
sync_past_days: int = 7
|
||||
sync_future_days: int = 30
|
||||
sync_past_days: int = Field(default=7, ge=0)
|
||||
sync_future_days: int = Field(default=30, ge=0)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
||||
@@ -230,13 +230,11 @@ class PipelineRunner:
|
||||
data = normalize_step(fetched, generated_at=now)
|
||||
|
||||
try:
|
||||
blog_result = fetch_blog_step(self._blog_client, self._blog_state)
|
||||
blog_articles = list(blog_result.articles)
|
||||
blog_articles = fetch_blog_step(self._blog_client, self._blog_state)
|
||||
except PipelineCriticalError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._warn("fetch_blog", self._redact(exc))
|
||||
blog_result = None
|
||||
blog_articles = []
|
||||
|
||||
try:
|
||||
@@ -292,11 +290,8 @@ class PipelineRunner:
|
||||
)
|
||||
if self._channel is not None and not self._dry_run:
|
||||
try:
|
||||
delivered = send_step(self._channel, message)
|
||||
if not delivered:
|
||||
if not send_step(self._channel, message):
|
||||
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:
|
||||
|
||||
@@ -2,28 +2,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pronote_sync.sources.blog.result import BlogRSSFetchResult
|
||||
from pronote_sync.models.blog import BlogArticle
|
||||
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) -> 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.
|
||||
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.
|
||||
|
||||
: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: Résultat de récupération, incluant les métadonnées de cache.
|
||||
:rtype: BlogRSSFetchResult
|
||||
:return: Nouveaux articles du blog.
|
||||
:rtype: list[BlogArticle]
|
||||
:raises RuntimeError: Si la récupération RSS injectée échoue.
|
||||
"""
|
||||
if client is None or state is None:
|
||||
return BlogRSSFetchResult()
|
||||
return []
|
||||
try:
|
||||
etag, last_modified = state.get_cache_headers()
|
||||
result = client.fetch_and_parse(
|
||||
@@ -31,8 +26,9 @@ 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 and not result.articles:
|
||||
if not result.not_modified:
|
||||
state.add_guids(article.id for article in result.articles)
|
||||
state.update_cache_headers(result.etag, result.last_modified)
|
||||
return result
|
||||
return list(result.articles)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Récupération du blog échouée : {redact_exception(exc)}") from None
|
||||
|
||||
@@ -19,7 +19,6 @@ 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__)
|
||||
@@ -157,22 +156,6 @@ 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.
|
||||
|
||||
|
||||
@@ -1040,110 +1040,6 @@ 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:
|
||||
|
||||
@@ -14,14 +14,11 @@ 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
|
||||
|
||||
|
||||
@@ -119,38 +116,6 @@ 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.
|
||||
|
||||
|
||||
+120
-3
@@ -10,10 +10,10 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
from pydantic import SecretStr, ValidationError
|
||||
|
||||
from pronote_sync.config.env import load_settings
|
||||
from pronote_sync.config.settings import PronoteSettings, Settings
|
||||
from pronote_sync.config.settings import AppSettings, PronoteSettings, Settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from _pytest.monkeypatch import MonkeyPatch
|
||||
@@ -207,4 +207,121 @@ def test_qr_pin_in_redaction_secrets(monkeypatch: MonkeyPatch) -> None:
|
||||
assert "**********" in repr(settings.pronote.qr_pin)
|
||||
|
||||
|
||||
# Ensure trailing newline
|
||||
def test_sync_past_days_negative_direct_instantiation() -> None:
|
||||
"""Vérifie que ``sync_past_days`` négatif lève ``ValidationError`` à l'instanciation.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
with pytest.raises(ValidationError):
|
||||
AppSettings(sync_past_days=-1)
|
||||
|
||||
|
||||
def test_sync_future_days_negative_direct_instantiation() -> None:
|
||||
"""Vérifie que ``sync_future_days`` négatif lève ``ValidationError`` à l'instanciation.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
with pytest.raises(ValidationError):
|
||||
AppSettings(sync_future_days=-1)
|
||||
|
||||
|
||||
def test_sync_past_days_zero_accepted() -> None:
|
||||
"""Vérifie que ``sync_past_days=0`` est accepté.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
settings = AppSettings(sync_past_days=0)
|
||||
assert settings.sync_past_days == 0
|
||||
|
||||
|
||||
def test_sync_future_days_zero_accepted() -> None:
|
||||
"""Vérifie que ``sync_future_days=0`` est accepté.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
settings = AppSettings(sync_future_days=0)
|
||||
assert settings.sync_future_days == 0
|
||||
|
||||
|
||||
def test_sync_past_days_positive_accepted() -> None:
|
||||
"""Vérifie que ``sync_past_days`` positif est accepté.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
settings = AppSettings(sync_past_days=7)
|
||||
assert settings.sync_past_days == 7
|
||||
|
||||
|
||||
def test_sync_future_days_positive_accepted() -> None:
|
||||
"""Vérifie que ``sync_future_days`` positif est accepté.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
settings = AppSettings(sync_future_days=30)
|
||||
assert settings.sync_future_days == 30
|
||||
|
||||
|
||||
def test_sync_past_days_negative_env_loading(monkeypatch: MonkeyPatch) -> None:
|
||||
"""Vérifie que ``SYNC_PAST_DAYS=-1`` lève ``ValidationError`` via chargement env.
|
||||
|
||||
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
||||
:return: None
|
||||
"""
|
||||
monkeypatch.setenv("SYNC_PAST_DAYS", "-1")
|
||||
with pytest.raises(ValidationError):
|
||||
load_settings()
|
||||
|
||||
|
||||
def test_sync_future_days_negative_env_loading(monkeypatch: MonkeyPatch) -> None:
|
||||
"""Vérifie que ``SYNC_FUTURE_DAYS=-1`` lève ``ValidationError`` via chargement env.
|
||||
|
||||
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
||||
:return: None
|
||||
"""
|
||||
monkeypatch.setenv("SYNC_FUTURE_DAYS", "-1")
|
||||
with pytest.raises(ValidationError):
|
||||
load_settings()
|
||||
|
||||
|
||||
def test_sync_past_days_zero_env_loading(monkeypatch: MonkeyPatch) -> None:
|
||||
"""Vérifie que ``SYNC_PAST_DAYS=0`` est accepté via chargement env.
|
||||
|
||||
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
||||
:return: None
|
||||
"""
|
||||
monkeypatch.setenv("SYNC_PAST_DAYS", "0")
|
||||
settings = load_settings()
|
||||
assert settings.app.sync_past_days == 0
|
||||
|
||||
|
||||
def test_sync_future_days_zero_env_loading(monkeypatch: MonkeyPatch) -> None:
|
||||
"""Vérifie que ``SYNC_FUTURE_DAYS=0`` est accepté via chargement env.
|
||||
|
||||
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
||||
:return: None
|
||||
"""
|
||||
monkeypatch.setenv("SYNC_FUTURE_DAYS", "0")
|
||||
settings = load_settings()
|
||||
assert settings.app.sync_future_days == 0
|
||||
|
||||
|
||||
def test_sync_past_days_positive_env_loading(monkeypatch: MonkeyPatch) -> None:
|
||||
"""Vérifie que ``SYNC_PAST_DAYS`` positif est accepté via chargement env.
|
||||
|
||||
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
||||
:return: None
|
||||
"""
|
||||
monkeypatch.setenv("SYNC_PAST_DAYS", "7")
|
||||
settings = load_settings()
|
||||
assert settings.app.sync_past_days == 7
|
||||
|
||||
|
||||
def test_sync_future_days_positive_env_loading(monkeypatch: MonkeyPatch) -> None:
|
||||
"""Vérifie que ``SYNC_FUTURE_DAYS`` positif est accepté via chargement env.
|
||||
|
||||
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
||||
:return: None
|
||||
"""
|
||||
monkeypatch.setenv("SYNC_FUTURE_DAYS", "30")
|
||||
settings = load_settings()
|
||||
assert settings.app.sync_future_days == 30
|
||||
|
||||
Reference in New Issue
Block a user