feat(M6): agenda théorique JSON avec parité des semaines et vacances scolaires

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>
This commit is contained in:
2026-09-06 23:06:29 +02:00
parent 4ec827a945
commit f9a1a5aa43
15 changed files with 1992 additions and 19 deletions

View File

@@ -0,0 +1,75 @@
"""Usine de construction du fournisseur d'agenda théorique.
Ce module expose l'API publique du package ``theoretical`` : les classes
:class:`~pronote_sync.sources.theoretical.provider.TheoreticalAgendaProvider`,
:class:`~pronote_sync.sources.theoretical.file.JsonTheoreticalAgendaProvider`,
:class:`~pronote_sync.sources.theoretical.parity.WeekParityService` et
:class:`~pronote_sync.sources.theoretical.holidays.SchoolHolidayCalendar`, ainsi
que la fonction :func:`get_theoretical_provider` qui assemble la configuration
(chemin du fichier JSON, parité des semaines et vacances scolaires) pour
produire un fournisseur d'agenda théorique prêt à l'emploi.
"""
from __future__ import annotations
from datetime import date
from typing import Literal
from pronote_sync.errors import PronoteSyncError
from pronote_sync.sources.theoretical.file import JsonTheoreticalAgendaProvider
from pronote_sync.sources.theoretical.holidays import SchoolHolidayCalendar
from pronote_sync.sources.theoretical.parity import WeekParityService
from pronote_sync.sources.theoretical.provider import TheoreticalAgendaProvider
__all__ = [
"TheoreticalAgendaProvider",
"JsonTheoreticalAgendaProvider",
"WeekParityService",
"SchoolHolidayCalendar",
"get_theoretical_provider",
]
def get_theoretical_provider(
agenda_path: str | None,
holidays_path: str | None,
anchor_date: date | None,
anchor_type: Literal["even", "odd"] | None,
) -> TheoreticalAgendaProvider | None:
"""Construit un fournisseur d'agenda théorique depuis la configuration.
:param agenda_path: Chemin du fichier JSON d'agenda théorique. Si None, retourne None.
:param holidays_path: Chemin du fichier JSON de vacances scolaires (optionnel).
:param anchor_date: Date de référence pour la parité des semaines.
:param anchor_type: Type de la semaine de référence ("even" ou "odd").
:return: Le fournisseur configuré, ou None si l'agenda théorique est désactivé.
:rtype: TheoreticalAgendaProvider | None
:raises PronoteSyncError: Si la configuration de parité est incomplète
(date sans type ou inversement) alors que l'agenda nécessite la parité.
"""
if agenda_path is None:
return None
# Build parity service if both anchor fields are provided
parity_service: WeekParityService | None = None
if anchor_date is not None and anchor_type is not None:
parity_service = WeekParityService(anchor_date, anchor_type)
elif anchor_date is not None or anchor_type is not None:
# Partial parity config — one field without the other
raise PronoteSyncError(
"Configuration de parité incomplète : THEORETICAL_WEEK_ANCHOR_DATE et "
"THEORETICAL_WEEK_ANCHOR_TYPE doivent être fournis ensemble."
)
# Build holiday calendar if path is provided
holiday_calendar: SchoolHolidayCalendar | None = None
if holidays_path is not None:
holiday_calendar = SchoolHolidayCalendar(holidays_path)
# Build provider — the provider's __init__ will validate that parity_service
# is provided if the JSON contains even/odd lessons
return JsonTheoreticalAgendaProvider(
file_path=agenda_path,
parity_service=parity_service,
holiday_calendar=holiday_calendar,
)

View File

@@ -0,0 +1,183 @@
"""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)

View File

@@ -0,0 +1,106 @@
"""Service de calendrier des vacances scolaires.
Ce module fournit les modèles de données :class:`HolidayPeriod` et
:class:`SchoolHolidayFile`, ainsi que le service :class:`SchoolHolidayCalendar`
qui charge un fichier JSON de périodes de vacances scolaires et permet de
déterminer si une date donnée tombe pendant ces vacances.
"""
from __future__ import annotations
import json
import logging
from datetime import date
from pathlib import Path
from typing import Any, Self
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pronote_sync.errors import PronoteSyncError
from pronote_sync.utils.redaction import redact_exception, redact_secrets
logger = logging.getLogger(__name__)
class HolidayPeriod(BaseModel):
"""Période de vacances scolaires, bornes incluses.
:ivar start_date: Date de début de la période (incluse).
:ivar end_date: Date de fin de la période (incluse).
:ivar label: Nom de la période (ex. « Toussaint »).
"""
model_config = ConfigDict(frozen=True)
start_date: date
end_date: date
label: str
@model_validator(mode="after")
def _validate_date_order(self) -> Self:
"""Vérifie que la date de fin n'est pas antérieure à la date de début.
:return: L'instance de période après validation.
:rtype: Self
:raises ValueError: Si ``end_date`` est strictement antérieure à ``start_date``.
"""
if self.end_date < self.start_date:
raise ValueError("end_date doit être supérieure ou égale à start_date.")
return self
class SchoolHolidayFile(BaseModel):
"""Modèle de parsing d'un fichier JSON de vacances scolaires.
:ivar zone: Zone académique (ex. « A »).
:ivar school_year: Année scolaire (ex. « 2026-2027 »).
:ivar periods: Périodes de vacances scolaires du fichier.
"""
zone: str
school_year: str
periods: tuple[HolidayPeriod, ...] = Field(default=())
class SchoolHolidayCalendar:
"""Calendrier des vacances scolaires chargé depuis un fichier JSON."""
def __init__(self, file_path: Path | str) -> None:
"""Charge les périodes de vacances scolaires depuis un fichier JSON.
:param file_path: Chemin vers le fichier JSON.
:raises PronoteSyncError: Si le fichier ne peut être lu ou analysé.
"""
path = Path(file_path)
self._periods: tuple[HolidayPeriod, ...]
if not path.is_file():
raise PronoteSyncError(
f"Le fichier de vacances scolaires est introuvable : {redact_secrets(str(path))}"
) from None
try:
data: Any = json.loads(path.read_text(encoding="utf-8"))
file_model: SchoolHolidayFile = SchoolHolidayFile.model_validate(data)
except Exception as exc:
logger.error(
"Fichier de vacances scolaires invalide %s : %s.",
redact_secrets(str(path)),
redact_exception(exc),
)
raise PronoteSyncError(
f"Le fichier de vacances scolaires est invalide : {redact_secrets(str(path))}"
) from None
self._periods = file_model.periods
def is_holiday(self, target_date: date) -> bool:
"""Vérifie si la date donnée tombe pendant une période de vacances.
La date est considérée comme étant en vacances si elle appartient à
l'intervalle d'au moins une période, bornes incluses
(``start_date <= target_date <= end_date``).
:param target_date: Date à vérifier.
:return: ``True`` si la date tombe pendant les vacances scolaires,
``False`` sinon.
:rtype: bool
"""
return any(period.start_date <= target_date <= period.end_date for period in self._periods)

View File

@@ -0,0 +1,123 @@
"""Modèles Pydantic de parsing du fichier JSON de l'agenda théorique.
Ce module définit les modèles de parsing utilisés pour lire le fichier
JSON de l'agenda théorique : :class:`TheoreticalLessonEntry` pour une
entrée de cours et :class:`TheoreticalAgendaFile` pour le fichier
complet.
Ces modèles sont distincts du modèle de domaine
:class:`~pronote_sync.models.agenda.TheoreticalLesson` : ils restent
proches du format JSON brut (heures au format ``HH:MM``) et servent
uniquement à la désérialisation, la conversion vers le modèle de domaine
étant réalisée ensuite par le fournisseur.
"""
from __future__ import annotations
import re
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
_TIME_PATTERN = re.compile(r"^(?:[01]\d|2[0-3]):[0-5]\d$")
class TheoreticalLessonEntry(BaseModel):
"""Représente une entrée de cours dans le fichier JSON de l'agenda théorique.
Modèle figé (``frozen``) : les instances sont immuables après
création. Le format des heures est validé (``HH:MM`` sur 24 heures,
avec ``HH`` entre ``00`` et ``23`` et ``MM`` entre ``00`` et ``59``)
ainsi que l'ordre des heures (fin postérieure au début).
:param week: Type de semaine auquel s'applique le cours
(``"all"``, ``"even"`` ou ``"odd"``).
:param day_of_week: Jour de la semaine (0 = lundi, 6 = dimanche).
:param start_time: Heure de début au format ``HH:MM`` sur 24 heures.
:param end_time: Heure de fin au format ``HH:MM`` sur 24 heures.
:param subject: Nom de la matière.
:param teachers: Noms des professeurs. Tuple vide par défaut.
:param rooms: Noms des salles. Tuple vide par défaut.
:param id: Identifiant explicite optionnel. ``None`` par défaut ; en
cas d'absence, le fournisseur en génère un.
"""
model_config = ConfigDict(frozen=True)
week: Literal["all", "even", "odd"] = Field(
..., description="Type de semaine concerné (all, even ou odd)"
)
day_of_week: int = Field(
..., ge=0, le=6, description="Jour de la semaine (0=lundi, 6=dimanche)"
)
start_time: str = Field(..., description="Heure de début au format HH:MM")
end_time: str = Field(..., description="Heure de fin au format HH:MM")
subject: str = Field(..., description="Nom de la matière")
teachers: tuple[str, ...] = Field(default=(), description="Noms des professeurs")
rooms: tuple[str, ...] = Field(default=(), description="Noms des salles")
id: str | None = Field(
default=None, description="Identifiant explicite optionnel (None si absent)"
)
@model_validator(mode="before")
@classmethod
def _validate_time_format(cls, data: Any) -> Any:
"""Valide le format ``HH:MM`` des heures de début et de fin.
Les heures doivent être au format ``HH:MM`` sur 24 heures, avec
``HH`` entre ``00`` et ``23`` et ``MM`` entre ``00`` et ``59``.
Cette validation précède :meth:`_validate_time_order`, dont la
comparaison par ordre lexicographique n'est fiable que si le
format est garanti.
:param data: Données brutes transmises au modèle.
:return: Les données brutes inchangées.
:rtype: Any
:raises ValueError: Si ``start_time`` ou ``end_time`` n'est pas
au format ``HH:MM``.
"""
if not isinstance(data, dict):
return data
for field_name in ("start_time", "end_time"):
if field_name not in data:
continue
value = data[field_name]
if not isinstance(value, str) or _TIME_PATTERN.fullmatch(value) is None:
raise ValueError(
f"{field_name} doit être au format HH:MM (HH entre 00 et 23, MM entre 00 et 59)"
)
return data
@model_validator(mode="after")
def _validate_time_order(self) -> TheoreticalLessonEntry:
"""Valide que l'heure de fin est postérieure à l'heure de début.
La comparaison est effectuée sur les chaînes ``HH:MM`` de façon
lexicographique ; elle n'est fiable que parce que
:meth:`_validate_time_format` a déjà garanti le format à deux
chiffres.
:return: L'instance validée.
:rtype: TheoreticalLessonEntry
:raises ValueError: Si ``end_time`` n'est pas postérieur à
``start_time``.
"""
if self.end_time <= self.start_time:
raise ValueError("end_time doit être postérieur à start_time")
return self
class TheoreticalAgendaFile(BaseModel):
"""Représente le fichier JSON complet de l'agenda théorique.
Modèle de parsing non figé : il sert uniquement à désérialiser le
fichier JSON avant conversion vers les modèles de domaine.
:param version: Version du schéma du fichier (vaut ``1``).
:param lessons: Liste des entrées de cours du fichier.
"""
version: Literal[1] = Field(default=1, description="Version du schéma (1)")
lessons: tuple[TheoreticalLessonEntry, ...] = Field(
..., description="Liste des entrées de cours"
)

View File

@@ -0,0 +1,55 @@
"""Service déterministe de calcul de la parité des semaines pour l'agenda théorique.
Ce module fournit :class:`WeekParityService`, un service sans état qui détermine
si la semaine contenant une date donnée est paire ou impaire, à partir d'une
date d'ancrage dont la parité est connue. L'algorithme repose sur le décalage
entre les lundis des deux semaines, et non sur les numéros de semaine ISO.
"""
from __future__ import annotations
from datetime import date, timedelta
from typing import Literal
class WeekParityService:
"""Service déterministe de calcul de la parité des semaines.
La parité d'une semaine est déduite d'une date d'ancrage fournie à la
construction : la semaine contenant cette date a une parité connue
(paire ou impaire). Le service est immuable après construction et ne
dépend d'aucun état global ni de l'horloge système.
"""
def __init__(self, anchor_date: date, anchor_type: Literal["even", "odd"]) -> None:
"""Initialise le service avec la date d'ancrage et sa parité.
:param anchor_date: Date de référence dont la semaine a une parité connue.
:param anchor_type: Parité de la semaine d'ancrage (``"even"`` ou ``"odd"``).
"""
self._anchor_monday = anchor_date - timedelta(days=anchor_date.weekday())
self._anchor_type = anchor_type
def parity_for(self, target_date: date) -> Literal["even", "odd"]:
"""Détermine la parité de la semaine contenant la date cible.
Algorithme :
1. Calculer le lundi de la semaine de la date cible.
2. Utiliser le lundi de la semaine de la date d'ancrage (stocké à
l'initialisation).
3. Calculer le nombre de semaines entre les deux lundis :
``(target_monday - anchor_monday).days // 7``.
4. Si le décalage de semaines est pair, la cible a la même parité que
l'ancrage.
5. Si le décalage de semaines est impair, la cible a la parité opposée.
:param target_date: Date dont il faut déterminer la parité.
:return: ``"even"`` ou ``"odd"`` selon la parité de la semaine cible.
:rtype: Literal["even", "odd"]
"""
target_monday = target_date - timedelta(days=target_date.weekday())
week_offset = (target_monday - self._anchor_monday).days // 7
if week_offset % 2 == 0:
return self._anchor_type
# Inverse la parité.
return "odd" if self._anchor_type == "even" else "even"

View File

@@ -0,0 +1,44 @@
"""Protocole pour les fournisseurs d'agenda théorique.
Ce module définit :class:`TheoreticalAgendaProvider`, le contrat que
tous les fournisseurs d'agenda théorique doivent respecter pour exposer
les cours théoriques (emploi du temps attendu) par date ou plage de
dates.
"""
from __future__ import annotations
from datetime import date
from typing import Protocol, runtime_checkable
from pronote_sync.models.agenda import TheoreticalLesson
@runtime_checkable
class TheoreticalAgendaProvider(Protocol):
"""Protocole pour un fournisseur d'agenda théorique.
Un fournisseur d'agenda théorique expose les cours théoriques
(emploi du temps attendu) pour une date ou une plage de dates.
L'implémentation encapsule la logique de filtrage par parité de
semaine et par vacances scolaires.
"""
def get_lessons(self, target_date: date) -> list[TheoreticalLesson]:
"""Retourne les cours théoriques applicables à la date donnée.
:param target_date: Date cible.
:return: Liste des cours théoriques triée par identifiant.
:rtype: list[TheoreticalLesson]
"""
...
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).
: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]
"""
...