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:
@@ -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,
|
||||
)
|
||||
|
||||
183
pronote_sync/sources/theoretical/file.py
Normal file
183
pronote_sync/sources/theoretical/file.py
Normal 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)
|
||||
106
pronote_sync/sources/theoretical/holidays.py
Normal file
106
pronote_sync/sources/theoretical/holidays.py
Normal 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)
|
||||
123
pronote_sync/sources/theoretical/model.py
Normal file
123
pronote_sync/sources/theoretical/model.py
Normal 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"
|
||||
)
|
||||
55
pronote_sync/sources/theoretical/parity.py
Normal file
55
pronote_sync/sources/theoretical/parity.py
Normal 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"
|
||||
44
pronote_sync/sources/theoretical/provider.py
Normal file
44
pronote_sync/sources/theoretical/provider.py
Normal 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]
|
||||
"""
|
||||
...
|
||||
Reference in New Issue
Block a user