fix(M3): immuabilité et validation des modèles — correction des écarts FIXME_M3
Immuabilité : - Collections des modèles frozen : list → tuple (Lesson, TheoreticalLesson, SchoolEvent, AgendaDiff, Homework, XmppMessage, ExternalInfo) - Gel de HomeworkBlock et ExternalInfo (sous-modèles imbriqués dans contrats) - Valeurs par défaut immuables : Field(default=(), ...) Validation de domaine : - TheoreticalLesson.day_of_week : Field(ge=0, le=6) - Lesson/TheoreticalLesson : start < end (validateur de modèle) - SchoolEvent : from_date <= to_date - AgendaChange : cohérence type/payload (ADDED/MODIFIED → lesson, REMOVED → theoretical_lesson) - CalDAVSyncResult : compteurs Field(ge=0) sur added/updated/removed Tests : 66 tests (construction/sérialisation, immuabilité, invariants) couvrant le gel, la mutabilité légitime, et les cas invalides par invariant. Co-authored-by: opencode/coder <coder@agents.invalid> Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
This commit is contained in:
@@ -8,7 +8,7 @@ from datetime import time as _time
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class Status(StrEnum):
|
||||
@@ -30,6 +30,8 @@ class LessonStatus(StrEnum):
|
||||
class HomeworkBlock(BaseModel):
|
||||
"""Représente un bloc de devoir extrait de la description d'un cours."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
kind: Literal["due", "assigned"] = Field(
|
||||
..., description="Type de bloc (échéance ou attribution)"
|
||||
)
|
||||
@@ -47,16 +49,28 @@ class Lesson(BaseModel):
|
||||
start: datetime = Field(..., description="Date/heure de début")
|
||||
end: datetime = Field(..., description="Date/heure de fin")
|
||||
subject: str = Field(..., description="Matière")
|
||||
teachers: list[str] = Field(default_factory=list, description="Liste des professeurs")
|
||||
rooms: list[str] = Field(default_factory=list, description="Liste des salles")
|
||||
teachers: tuple[str, ...] = Field(default=(), description="Liste des professeurs")
|
||||
rooms: tuple[str, ...] = Field(default=(), description="Liste des salles")
|
||||
group: str | None = Field(None, description="Groupe (ex: Classe entière)")
|
||||
status: LessonStatus = Field(default=LessonStatus.NORMAL, description="Statut du cours")
|
||||
content: str | None = Field(None, description="Contenu pédagogique")
|
||||
homework_blocks: list[HomeworkBlock] = Field(
|
||||
default_factory=list,
|
||||
homework_blocks: tuple[HomeworkBlock, ...] = Field(
|
||||
default=(),
|
||||
description="Blocs de devoirs extraits de la description",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_time_order(self) -> Lesson:
|
||||
"""Valide que l'heure de fin est postérieure à l'heure de début.
|
||||
|
||||
:return: L'instance validée.
|
||||
:rtype: Lesson
|
||||
:raises ValueError: Si ``end`` n'est pas postérieur à ``start``.
|
||||
"""
|
||||
if self.end <= self.start:
|
||||
raise ValueError("end doit être postérieur à start")
|
||||
return self
|
||||
|
||||
|
||||
class SchoolEventKind(StrEnum):
|
||||
"""Type d'événement scolaire."""
|
||||
@@ -75,6 +89,18 @@ class SchoolEvent(BaseModel):
|
||||
from_date: _date = Field(..., description="Date de début (inclusive)")
|
||||
to_date: _date = Field(..., description="Date de fin (exclusive)")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_date_order(self) -> SchoolEvent:
|
||||
"""Valide que la date de fin est postérieure ou égale à la date de début.
|
||||
|
||||
:return: L'instance validée.
|
||||
:rtype: SchoolEvent
|
||||
:raises ValueError: Si ``to_date`` est antérieur à ``from_date``.
|
||||
"""
|
||||
if self.to_date < self.from_date:
|
||||
raise ValueError("to_date doit être postérieur ou égal à from_date")
|
||||
return self
|
||||
|
||||
|
||||
class TheoreticalLesson(BaseModel):
|
||||
"""Représente un cours dans l'agenda théorique."""
|
||||
@@ -82,9 +108,23 @@ class TheoreticalLesson(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str = Field(..., description="Identifiant unique")
|
||||
day_of_week: int = Field(..., description="Jour de la semaine (0=lundi, 6=dimanche)")
|
||||
day_of_week: int = Field(
|
||||
..., ge=0, le=6, description="Jour de la semaine (0=lundi, 6=dimanche)"
|
||||
)
|
||||
start_time: _time = Field(..., description="Heure de début")
|
||||
end_time: _time = Field(..., description="Heure de fin")
|
||||
subject: str = Field(..., description="Matière")
|
||||
teachers: list[str] = Field(default_factory=list, description="Liste des professeurs")
|
||||
rooms: list[str] = Field(default_factory=list, description="Liste des salles")
|
||||
teachers: tuple[str, ...] = Field(default=(), description="Liste des professeurs")
|
||||
rooms: tuple[str, ...] = Field(default=(), description="Liste des salles")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_time_order(self) -> TheoreticalLesson:
|
||||
"""Valide que l'heure de fin est postérieure à l'heure de début.
|
||||
|
||||
:return: L'instance validée.
|
||||
:rtype: TheoreticalLesson
|
||||
: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
|
||||
|
||||
@@ -26,17 +26,22 @@ class BlogArticle(BaseModel):
|
||||
|
||||
|
||||
class ExternalInfo(BaseModel):
|
||||
"""Agrège les informations externes (blog, messages Pronote) pour le message XMPP."""
|
||||
"""Agrège les informations externes (blog, messages Pronote) pour le message XMPP.
|
||||
|
||||
blog_articles: list[BlogArticle] = Field(
|
||||
default_factory=list,
|
||||
Modèle figé (``frozen``) : les instances sont immuables après création.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
blog_articles: tuple[BlogArticle, ...] = Field(
|
||||
default=(),
|
||||
description="Liste des nouveaux articles du blog",
|
||||
)
|
||||
pronote_messages: list[Message] = Field(
|
||||
default_factory=list,
|
||||
pronote_messages: tuple[Message, ...] = Field(
|
||||
default=(),
|
||||
description="Liste des messages Pronote",
|
||||
)
|
||||
other_info: list[str] = Field(
|
||||
default_factory=list,
|
||||
other_info: tuple[str, ...] = Field(
|
||||
default=(),
|
||||
description="Autres informations (extensible)",
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from datetime import date
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from pronote_sync.models.agenda import Lesson, TheoreticalLesson
|
||||
|
||||
@@ -30,6 +30,22 @@ class AgendaChange(BaseModel):
|
||||
)
|
||||
details: str = Field(default="", description="Détails du changement")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_payload_consistency(self) -> AgendaChange:
|
||||
"""Valide la cohérence entre le type de changement et le payload.
|
||||
|
||||
:return: L'instance validée.
|
||||
:rtype: AgendaChange
|
||||
:raises ValueError: Si le payload ne correspond pas au type de changement.
|
||||
"""
|
||||
if self.type in (AgendaChangeType.ADDED, AgendaChangeType.MODIFIED):
|
||||
if self.lesson is None:
|
||||
raise ValueError(f"lesson est requis pour le type {self.type!r}")
|
||||
if self.type == AgendaChangeType.REMOVED:
|
||||
if self.theoretical_lesson is None:
|
||||
raise ValueError(f"theoretical_lesson est requis pour le type {self.type!r}")
|
||||
return self
|
||||
|
||||
|
||||
class AgendaDiff(BaseModel):
|
||||
"""Représente les différences entre l'agenda réel et l'agenda théorique."""
|
||||
@@ -37,4 +53,4 @@ class AgendaDiff(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
target_date: date = Field(..., description="Date cible de la comparaison")
|
||||
changes: list[AgendaChange] = Field(default_factory=list, description="Liste des changements")
|
||||
changes: tuple[AgendaChange, ...] = Field(default=(), description="Liste des changements")
|
||||
|
||||
@@ -14,7 +14,7 @@ class Homework(BaseModel):
|
||||
|
||||
id: str = Field(..., description="ID stable (hachage)")
|
||||
subject: str = Field(..., description="Matière")
|
||||
teachers: list[str] = Field(default_factory=list, description="Liste des professeurs")
|
||||
teachers: tuple[str, ...] = Field(default=(), description="Liste des professeurs")
|
||||
assigned_on: date | None = Field(None, description="Date de distribution")
|
||||
due_on: date = Field(..., description="Date d'échéance")
|
||||
text: str = Field(..., description="Texte du devoir (brut)")
|
||||
|
||||
@@ -36,7 +36,7 @@ class CalDAVSyncResult(BaseModel):
|
||||
"""Résultat d'une synchronisation CalDAV (mutable)."""
|
||||
|
||||
status: CalDAVSyncStatus = Field(..., description="Statut global")
|
||||
added: int = Field(0, description="Nombre d'événements ajoutés")
|
||||
updated: int = Field(0, description="Nombre d'événements mis à jour")
|
||||
removed: int = Field(0, description="Nombre d'événements supprimés")
|
||||
added: int = Field(0, ge=0, description="Nombre d'événements ajoutés")
|
||||
updated: int = Field(0, ge=0, description="Nombre d'événements mis à jour")
|
||||
removed: int = Field(0, ge=0, description="Nombre d'événements supprimés")
|
||||
errors: list[str] = Field(default_factory=list, description="Liste des erreurs")
|
||||
|
||||
@@ -19,13 +19,11 @@ class XmppMessage(BaseModel):
|
||||
|
||||
target_date: date = Field(..., description="Date cible")
|
||||
synthesis: str | None = Field(None, description="Synthèse IA (optionnelle, 3-5 phrases)")
|
||||
homeworks: list[Homework] = Field(default_factory=list, description="Liste brute des devoirs")
|
||||
changes: list[AgendaChange] = Field(
|
||||
default_factory=list, description="Liste des changements d'agenda"
|
||||
)
|
||||
messages: list[Message] = Field(
|
||||
default_factory=list, description="Liste des messages/informations"
|
||||
homeworks: tuple[Homework, ...] = Field(default=(), description="Liste brute des devoirs")
|
||||
changes: tuple[AgendaChange, ...] = Field(
|
||||
default=(), description="Liste des changements d'agenda"
|
||||
)
|
||||
messages: tuple[Message, ...] = Field(default=(), description="Liste des messages/informations")
|
||||
external_info: ExternalInfo | None = Field(
|
||||
None, description="Informations externes (blog, messages Pronote)"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user