Implémentation complète de la source d'agenda théorique : - model.py : modèles Pydantic de parsing JSON (TheoreticalLessonEntry, TheoreticalAgendaFile) avec validation des formats d'heure et de l'ordre début/fin. - parity.py : WeekParityService déterministe calculant la parité d'une semaine (paire/impaire) à partir d'une date de référence. - holidays.py : SchoolHolidayCalendar lisant un fichier JSON de vacances scolaires (zone A) avec bornes inclusives. - provider.py : protocole TheoreticalAgendaProvider (get_lessons, get_lessons_for_range). - file.py : JsonTheoreticalAgendaProvider implémentant le protocole : filtrage par parité et vacances, génération d'IDs déterministes incluant le type de semaine, validation de l'unicité des IDs, tri stable par identifiant. - __init__.py : factory get_theoretical_provider câblant la configuration (None si désactivé, erreur si config de parité partielle). - Fixtures : theoretical.json (9 leçons all/even/odd) et school_holidays.json (zone A, 4 périodes). - 57 tests unitaires couvrant parsing, parité, vacances, provider, factory, déduplication de range, collisions d'IDs. - Guide : §8 et §12 alignés avec le format JSON. Co-authored-by: opencode/coder <coder@agents.invalid> Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
184 lines
8.7 KiB
Python
184 lines
8.7 KiB
Python
"""Fournisseur d'agenda théorique basé sur un fichier JSON.
|
|
|
|
Ce module fournit :class:`JsonTheoreticalAgendaProvider`, une implémentation de
|
|
:class:`~pronote_sync.sources.theoretical.provider.TheoreticalAgendaProvider` qui charge
|
|
un fichier JSON d'emploi du temps théorique et expose les cours applicables par date ou
|
|
plage de dates. Le filtrage tient compte du jour de la semaine, de la parité de semaine
|
|
(``even``/``odd``) et du calendrier des vacances scolaires.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import date, time, timedelta
|
|
from pathlib import Path
|
|
from typing import Literal
|
|
|
|
from pronote_sync.errors import PronoteSyncError
|
|
from pronote_sync.models.agenda import TheoreticalLesson
|
|
from pronote_sync.sources.theoretical.holidays import SchoolHolidayCalendar
|
|
from pronote_sync.sources.theoretical.model import TheoreticalAgendaFile, TheoreticalLessonEntry
|
|
from pronote_sync.sources.theoretical.parity import WeekParityService
|
|
from pronote_sync.utils.redaction import redact_exception, redact_secrets
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _generate_id(entry: TheoreticalLessonEntry) -> str:
|
|
"""Génère un identifiant déterministe pour une entrée de cours.
|
|
|
|
L'identifiant intègre le type de semaine (``all``, ``even`` ou ``odd``),
|
|
le jour de la semaine, le créneau horaire et la matière : deux leçons
|
|
occupant le même créneau dans des semaines différentes (ou le même
|
|
créneau un autre jour) obtiennent ainsi des identifiants distincts.
|
|
|
|
:param entry: Entrée de cours du fichier JSON.
|
|
:return: Identifiant déterministe unique.
|
|
:rtype: str
|
|
"""
|
|
subject_slug = entry.subject.lower().strip().replace(" ", "-")
|
|
return f"theoretical:{entry.week}:{entry.day_of_week}:{entry.start_time}-{entry.end_time}:{subject_slug}"
|
|
|
|
|
|
class JsonTheoreticalAgendaProvider:
|
|
"""Fournisseur d'agenda théorique basé sur un fichier JSON.
|
|
|
|
Charge un fichier JSON d'emploi du temps théorique au format défini par
|
|
:class:`~pronote_sync.sources.theoretical.model.TheoreticalAgendaFile` et expose
|
|
les cours théoriques pour une date ou une plage de dates. Les cours peuvent être
|
|
restreints à une parité de semaine (paire/impaire) via
|
|
:class:`~pronote_sync.sources.theoretical.parity.WeekParityService` et exclus
|
|
pendant les vacances scolaires via
|
|
:class:`~pronote_sync.sources.theoretical.holidays.SchoolHolidayCalendar`.
|
|
|
|
:param file_path: Chemin vers le fichier JSON de l'agenda théorique.
|
|
:param parity_service: Service optionnel de calcul de la parité de semaine.
|
|
:param holiday_calendar: Calendrier optionnel des vacances scolaires.
|
|
:raises PronoteSyncError: Si le fichier ne peut être lu ou analysé, si
|
|
des leçons à semaine paire/impaire sont présentes sans ancre de parité,
|
|
ou si plusieurs leçons partagent le même identifiant (explicite ou
|
|
généré).
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
file_path: str,
|
|
parity_service: WeekParityService | None = None,
|
|
holiday_calendar: SchoolHolidayCalendar | None = None,
|
|
) -> None:
|
|
"""Initialise le fournisseur en chargeant et analysant le fichier JSON.
|
|
|
|
Le fichier est lu et analysé immédiatement. Toute erreur de lecture,
|
|
de décodage JSON ou de validation est journalisée (chemin et exception
|
|
expurgés) puis remontée sous forme de :class:`PronoteSyncError`. Si des
|
|
leçons à semaine paire/impaire sont présentes alors qu'aucun service de
|
|
parité n'est configuré, une :class:`PronoteSyncError` est également levée.
|
|
|
|
:param file_path: Chemin vers le fichier JSON de l'agenda théorique.
|
|
:param parity_service: Service optionnel de calcul de la parité de semaine.
|
|
:param holiday_calendar: Calendrier optionnel des vacances scolaires.
|
|
:raises PronoteSyncError: Si le fichier est introuvable, invalide,
|
|
nécessite une ancre de parité non configurée ou contient plusieurs
|
|
leçons partageant le même identifiant (explicite ou généré).
|
|
"""
|
|
self._file_path: str = file_path
|
|
self._parity_service: WeekParityService | None = parity_service
|
|
self._holiday_calendar: SchoolHolidayCalendar | None = holiday_calendar
|
|
try:
|
|
content = Path(file_path).read_text(encoding="utf-8")
|
|
parsed = TheoreticalAgendaFile.model_validate_json(content)
|
|
except Exception as exc:
|
|
logger.error(
|
|
"Fichier d'agenda théorique invalide %s : %s.",
|
|
redact_secrets(str(file_path)),
|
|
redact_exception(exc),
|
|
)
|
|
raise PronoteSyncError(
|
|
f"Le fichier d'agenda théorique est invalide : {redact_secrets(str(file_path))}"
|
|
) from None
|
|
self._lessons: tuple[TheoreticalLessonEntry, ...] = parsed.lessons
|
|
if self._parity_service is None and any(
|
|
entry.week in ("even", "odd") for entry in self._lessons
|
|
):
|
|
raise PronoteSyncError(
|
|
"L'agenda théorique contient des leçons à semaine paire/impaire "
|
|
"mais aucune ancre de parité n'est configurée "
|
|
"(THEORETICAL_WEEK_ANCHOR_DATE et THEORETICAL_WEEK_ANCHOR_TYPE)"
|
|
) from None
|
|
seen_ids: set[str] = set()
|
|
for entry in self._lessons:
|
|
effective_id = entry.id if entry.id is not None else _generate_id(entry)
|
|
if effective_id in seen_ids:
|
|
raise PronoteSyncError(
|
|
f"Conflit d'identifiant dans l'agenda théorique : "
|
|
f"l'identifiant '{effective_id}' est utilisé par plusieurs leçons. "
|
|
f"Fournissez des identifiants explicites uniques."
|
|
) from None
|
|
seen_ids.add(effective_id)
|
|
|
|
def get_lessons(self, target_date: date) -> list[TheoreticalLesson]:
|
|
"""Retourne les cours théoriques applicables à la date donnée.
|
|
|
|
Si un calendrier de vacances est configuré et que la date tombe pendant
|
|
une période de vacances, la liste retournée est vide. La parité de la
|
|
semaine est déterminée via le service de parité lorsqu'il est configuré ;
|
|
sinon seuls les cours de type ``all`` sont conservés. Les entrées sont
|
|
ensuite filtrées par jour de la semaine, converties en
|
|
:class:`~pronote_sync.models.agenda.TheoreticalLesson` et triées par
|
|
identifiant.
|
|
|
|
:param target_date: Date cible.
|
|
:return: Liste des cours théoriques triée par identifiant.
|
|
:rtype: list[TheoreticalLesson]
|
|
"""
|
|
if self._holiday_calendar is not None and self._holiday_calendar.is_holiday(target_date):
|
|
return []
|
|
|
|
week_parity: Literal["all", "even", "odd"]
|
|
if self._parity_service is not None:
|
|
week_parity = self._parity_service.parity_for(target_date)
|
|
else:
|
|
week_parity = "all"
|
|
|
|
lessons: list[TheoreticalLesson] = []
|
|
for entry in self._lessons:
|
|
if entry.week != "all" and entry.week != week_parity:
|
|
continue
|
|
if entry.day_of_week != target_date.weekday():
|
|
continue
|
|
lesson_id = entry.id if entry.id is not None else _generate_id(entry)
|
|
lessons.append(
|
|
TheoreticalLesson(
|
|
id=lesson_id,
|
|
day_of_week=entry.day_of_week,
|
|
start_time=time.fromisoformat(entry.start_time),
|
|
end_time=time.fromisoformat(entry.end_time),
|
|
subject=entry.subject,
|
|
teachers=entry.teachers,
|
|
rooms=entry.rooms,
|
|
)
|
|
)
|
|
return sorted(lessons, key=lambda lesson: lesson.id)
|
|
|
|
def get_lessons_for_range(self, start_date: date, end_date: date) -> list[TheoreticalLesson]:
|
|
"""Retourne les cours théoriques pour une plage de dates (inclusives).
|
|
|
|
Chaque date de la plage, bornes incluses, est évaluée via
|
|
:meth:`get_lessons`. Les cours sont dédupliqués par identifiant : pour
|
|
un identifiant donné, la dernière occurrence (date la plus récente)
|
|
écrase la précédente. Si ``start_date`` est postérieure à ``end_date``,
|
|
la liste retournée est vide.
|
|
|
|
:param start_date: Date de début (inclusive).
|
|
:param end_date: Date de fin (inclusive).
|
|
:return: Liste des cours théoriques triée par identifiant.
|
|
:rtype: list[TheoreticalLesson]
|
|
"""
|
|
seen: dict[str, TheoreticalLesson] = {}
|
|
current_date = start_date
|
|
while current_date <= end_date:
|
|
for lesson in self.get_lessons(current_date):
|
|
seen[lesson.id] = lesson
|
|
current_date += timedelta(days=1)
|
|
return sorted(seen.values(), key=lambda lesson: lesson.id)
|