fix: serialize QR token client operations

This commit is contained in:
2026-09-10 21:24:11 +02:00
parent 5188761209
commit 22a662ab39
2 changed files with 216 additions and 103 deletions

View File

@@ -12,6 +12,8 @@ from __future__ import annotations
import json
import logging
from collections.abc import Generator
from contextlib import contextmanager
from datetime import date
from pathlib import Path
from typing import Any, Protocol
@@ -252,6 +254,26 @@ class PronoteClient:
except Exception as exc:
logger.debug("Échec de la persistance des credentials : %s", redact_exception(exc))
@contextmanager
def _qr_token_operation_lock(self) -> Generator[None]:
"""Verrouille un cycle d'authentification et de récupération QR/token.
Le verrou englobe le chargement du token, le login, l'opération de
données et la persistance qui suit. Il est volontairement absent du
mode ``password``, qui ne partage pas de fichier d'état de token.
:return: Un gestionnaire de contexte protégeant le cycle QR/token.
:rtype: collections.abc.Generator[None, None, None]
:raises PronoteAuthStateLockError: Si l'état QR/token est déjà utilisé
par une autre opération.
"""
if self._settings.auth_mode != "qr_token" or self._auth_state is None:
yield
return
with self._auth_state.lock():
yield
def _connect_password(self) -> pronotepy.Client:
"""Connecte le client ``pronotepy`` en mode ``password``.
@@ -426,37 +448,38 @@ class PronoteClient:
:return: Liste des messages des professeurs ; vide en cas d'erreur.
:rtype: list[Message]
"""
try:
client = self._connect()
messages: list[Message] = []
for discussion in client.discussions():
for message in discussion.messages:
messages.append(
Message(
id=message.id,
type=MessageType.DISCUSSION,
title=discussion.subject,
content=message.content,
author=message.author or "",
date=message.created,
read=message.seen,
with self._qr_token_operation_lock():
try:
client = self._connect()
messages: list[Message] = []
for discussion in client.discussions():
for message in discussion.messages:
messages.append(
Message(
id=message.id,
type=MessageType.DISCUSSION,
title=discussion.subject,
content=message.content,
author=message.author or "",
date=message.created,
read=message.seen,
)
)
)
self._persist_credentials()
return messages
except (
pronotepy.PronoteAPIError,
ValueError,
requests.RequestException,
ConnectionError,
TimeoutError,
) as exc:
logger.error(
"Échec de la récupération des messages Pronote : %s",
redact_exception(exc),
)
self._persist_credentials()
return []
self._persist_credentials()
return messages
except (
pronotepy.PronoteAPIError,
ValueError,
requests.RequestException,
ConnectionError,
TimeoutError,
) as exc:
logger.error(
"Échec de la récupération des messages Pronote : %s",
redact_exception(exc),
)
self._persist_credentials()
return []
def get_informations(self) -> list[Message]:
"""Récupère les informations et sondages Pronote.
@@ -467,36 +490,37 @@ class PronoteClient:
:return: Liste des informations et sondages ; vide en cas d'erreur.
:rtype: list[Message]
"""
try:
client = self._connect()
messages: list[Message] = []
for info in client.information_and_surveys():
messages.append(
Message(
id=info.id,
type=MessageType.SURVEY if info.survey else MessageType.INFORMATION,
title=info.title or "",
content=info.content(),
author=info.author,
date=info.creation_date,
read=info.read,
with self._qr_token_operation_lock():
try:
client = self._connect()
messages: list[Message] = []
for info in client.information_and_surveys():
messages.append(
Message(
id=info.id,
type=MessageType.SURVEY if info.survey else MessageType.INFORMATION,
title=info.title or "",
content=info.content(),
author=info.author,
date=info.creation_date,
read=info.read,
)
)
self._persist_credentials()
return messages
except (
pronotepy.PronoteAPIError,
ValueError,
requests.RequestException,
ConnectionError,
TimeoutError,
) as exc:
logger.error(
"Échec de la récupération des informations Pronote : %s",
redact_exception(exc),
)
self._persist_credentials()
return messages
except (
pronotepy.PronoteAPIError,
ValueError,
requests.RequestException,
ConnectionError,
TimeoutError,
) as exc:
logger.error(
"Échec de la récupération des informations Pronote : %s",
redact_exception(exc),
)
self._persist_credentials()
return []
self._persist_credentials()
return []
def get_lessons(self, start: date, end: date) -> list[Lesson]:
"""Récupère les cours via ``pronotepy`` (repli iCal).
@@ -523,37 +547,38 @@ class PronoteClient:
:raises ConnectionError: Si la connexion réseau échoue.
:raises TimeoutError: Si la requête réseau expire.
"""
client = self._connect()
lessons: list[Lesson] = []
for lesson in client.lessons(start, end):
content = lesson.content
raw_uid = lesson.id
if raw_uid:
uid = normalize_pronote_uid(raw_uid)
else:
uid = generate_deterministic_uid(
start=lesson.start,
end=lesson.end,
subject=lesson.subject.name if lesson.subject is not None else "",
teachers=list(lesson.teacher_names or ()),
rooms=list(lesson.classrooms or ()),
group=lesson.group_name,
with self._qr_token_operation_lock():
client = self._connect()
lessons: list[Lesson] = []
for lesson in client.lessons(start, end):
content = lesson.content
raw_uid = lesson.id
if raw_uid:
uid = normalize_pronote_uid(raw_uid)
else:
uid = generate_deterministic_uid(
start=lesson.start,
end=lesson.end,
subject=lesson.subject.name if lesson.subject is not None else "",
teachers=list(lesson.teacher_names or ()),
rooms=list(lesson.classrooms or ()),
group=lesson.group_name,
)
lessons.append(
Lesson(
id=uid,
start=lesson.start,
end=lesson.end,
subject=lesson.subject.name if lesson.subject is not None else "",
teachers=tuple(lesson.teacher_names or ()),
rooms=tuple(lesson.classrooms or ()),
group=lesson.group_name,
status=(LessonStatus.CANCELLED if lesson.canceled else LessonStatus.NORMAL),
content=content.description if content is not None else None,
)
)
lessons.append(
Lesson(
id=uid,
start=lesson.start,
end=lesson.end,
subject=lesson.subject.name if lesson.subject is not None else "",
teachers=tuple(lesson.teacher_names or ()),
rooms=tuple(lesson.classrooms or ()),
group=lesson.group_name,
status=(LessonStatus.CANCELLED if lesson.canceled else LessonStatus.NORMAL),
content=content.description if content is not None else None,
)
)
self._persist_credentials()
return lessons
self._persist_credentials()
return lessons
def get_homeworks(self, start: date, end: date) -> list[Homework]:
"""Récupère les devoirs via ``pronotepy``.
@@ -576,19 +601,20 @@ class PronoteClient:
:raises ConnectionError: Si la connexion réseau échoue.
:raises TimeoutError: Si la requête réseau expire.
"""
client = self._connect()
homeworks: list[Homework] = []
for hw in client.homework(start, end):
homeworks.append(
Homework(
id=hw.id,
subject=hw.subject.name,
teachers=(),
assigned_on=None,
due_on=hw.date,
text=hw.description,
html=hw.description,
with self._qr_token_operation_lock():
client = self._connect()
homeworks: list[Homework] = []
for hw in client.homework(start, end):
homeworks.append(
Homework(
id=hw.id,
subject=hw.subject.name,
teachers=(),
assigned_on=None,
due_on=hw.date,
text=hw.description,
html=hw.description,
)
)
)
self._persist_credentials()
return homeworks
self._persist_credentials()
return homeworks