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>
124 lines
5.0 KiB
Python
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"
|
|
)
|