Merge remote-tracking branch 'origin/main' into feat/issue-8-account-pin
# Conflicts: # docs/exploitation.md
This commit is contained in:
@@ -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__":
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user