Files
Antoine Van Elstraete f9a1a5aa43 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>
2026-09-06 23:06:29 +02:00

124 lines
5.0 KiB
Python

"""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"
)