Compare commits

..

8 Commits

Author SHA1 Message Date
ebbe39f1f0 docs(M7): aligner guide, TODO et pre-commit pour la sync CalDAV
Met à jour la documentation et la configuration pour le jalon M7 selon
les décisions d'architecture :

- GUIDE_DEV_PYTHON.md §7 : API réelle caldav>=1.3.0 (pas le pseudo-code),
  calendar_path (pas calendar_name), plan CalDAVSyncPlan explicite avant
  exécution, pas d'état local (scan distant), événements non marqués
  jamais modifiés
- TODO.md M7 : suppression de sync/state.py et BlogRSSState, ajout de
  l'exécution du plan et de la protection des événements non gérés
- .pre-commit-config.yaml : caldav>=1.3.0 ajouté aux additional_dependencies
  du hook mypy

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
2026-09-07 00:00:54 +02:00
958bb3ec5d merge: corrections d'audit FIXME_M6 dans l'agenda théorique
Intègre les corrections de la relecture indépendante :
- Normalisation des matières (NFKC + espaces + ponctuation + casse)
- Expurgation du secret dans l'erreur de collision d'IDs
- Tests renforcés (parité isolée, normalisation, non-fuite)
- Documentation alignée (TODO coché, guide §8.4 corrigé)

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-06 23:31:25 +02:00
1d26d49e74 fix(M6): corrections d'audit FIXME_M6 — normalisation, secret, tests, doc
Corrige les 5 points de l'audit FIXME_M6 :

1. Normalisation des matières : fonction normalize_subject (NFKC +
   unification des espaces + suppression ponctuation + minuscule)
   partagée par la génération d'ID et le futur comparateur M8.
2. Expurgation du secret dans l'erreur de collision d'IDs :
   redact_secrets enveloppe l'identifiant dans le message.
3. Test even/odd avec même matière pour isoler la parité comme seul
   différenciateur d'ID ; tests de normalisation (casse, espaces,
   Unicode) ; test de non-fuite de secret.
4. TODO.md M6 : 8 items cochés après validation.
5. GUIDE_DEV §8.4 : bloc de code corrigé (clôture, types Lesson/
   TheoreticalLesson, comparaison des horaires en minutes, début ET
   fin, référence à normalize_subject).

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-06 23:30:47 +02:00
29270427ef merge: jalon M6 — agenda théorique JSON avec parité et vacances scolaires
Intègre le jalon M6 complet :
- Source d'agenda théorique au format JSON (avec parité paire/impaire)
- Service de parité des semaines (WeekParityService) basé sur une date
  de référence configurée
- Calendrier de vacances scolaires (SchoolHolidayCalendar, zone A)
- Provider JSON avec filtrage par parité et vacances
- Factory de câblage de configuration
- Fixtures theoretical.json et school_holidays.json
- 57 tests unitaires
- Documentation alignée (TODO.md, GUIDE_DEV_PYTHON.md, .env.example)

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-06 23:06:47 +02:00
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
4ec827a945 docs(M6): aligner guide, TODO et configuration pour l'agenda théorique JSON
Met à jour la documentation et la configuration pour le jalon M6 selon
les décisions d'architecture :

- GUIDE_DEV_PYTHON.md §8 : remplace iCal/CSV par JSON avec parité de
  semaine (paire/impaire) et calendrier de vacances scolaires séparé
- TODO.md M6 : nouveaux items (WeekParityService, SchoolHolidayCalendar,
  configuration, fixtures JSON)
- .env.example : THEORETICAL_AGENDA_PATH passe en .json, ajout de
  SCHOOL_HOLIDAYS_PATH, THEORETICAL_WEEK_ANCHOR_DATE et
  THEORETICAL_WEEK_ANCHOR_TYPE
- AppSettings : 3 nouveaux champs (school_holidays_path,
  theoretical_week_anchor_date, theoretical_week_anchor_type)

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
2026-09-06 22:30:50 +02:00
7fdca2ca55 merge: jalon M5 — source blog RSS (fetch, parsing, déduplication, état)
Intègre le jalon M5 complet incluant les corrections d'audit FIXME_M5 :
- BlogRSSClient : client sans état, transport HTTP via requests,
  parsing feedparser, déduplication par known_guids, cache HTTP
  conditionnel, tri déterministe, mode dégradé complet
- BlogRSSFetchResult : résultat immuable (articles + cache + not_modified)
- BlogRSSState : persistance JSON atomique et tolérante
- Fixture blog_rss.xml anonymisée (3 articles, dates fixes)
- 49 tests unitaires (32 client + 17 state)
- Documentation TODO.md et GUIDE_DEV_PYTHON.md alignés
- Configuration pre-commit : feedparser ajouté au hook mypy

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-06 20:58:18 +02:00
344745d725 merge: corrections d'audit FIXME_M5 dans M5 blog RSS
Intègre les corrections de la revue indépendante (FIXME_M5.md) :
- Transport HTTP séparé du parsing (requests.get + feedparser.parse)
- Rejet des statuts HTTP d'erreur (raise_for_status)
- Préservation des validateurs de cache sur les chemins d'échec
- Sauvegarde atomique de l'état (tmp + Path.replace)
- Déduplication normale silencieuse

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-06 20:58:13 +02:00
19 changed files with 2477 additions and 390 deletions

View File

@@ -22,7 +22,10 @@ SYNC_PAST_DAYS=7
SYNC_FUTURE_DAYS=30
# --- Agenda théorique ---
THEORETICAL_AGENDA_PATH=./data/theoretical.ics
THEORETICAL_AGENDA_PATH=./data/theoretical.json
SCHOOL_HOLIDAYS_PATH=./data/school_holidays.json
THEORETICAL_WEEK_ANCHOR_DATE=2026-09-01
THEORETICAL_WEEK_ANCHOR_TYPE=even
# --- XMPP ---
XMPP_ENABLED=false

View File

@@ -26,7 +26,7 @@ repos:
name: mypy
entry: mypy
language: python
additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "pytest>=8.0.0", "types-requests>=2.31.0", "icalendar>=5.0.0", "pronotepy>=2.15.0", "responses>=0.25.0", "pytest-mock>=3.10.0", "feedparser>=6.0.0"]
additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "pytest>=8.0.0", "types-requests>=2.31.0", "icalendar>=5.0.0", "pronotepy>=2.15.0", "responses>=0.25.0", "pytest-mock>=3.10.0", "feedparser>=6.0.0", "caldav>=1.3.0"]
types: [python]
pass_filenames: true

View File

@@ -140,10 +140,10 @@
"filename": "GUIDE_DEV_PYTHON.md",
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
"is_verified": true,
"line_number": 5058,
"line_number": 5034,
"is_secret": false
}
]
},
"generated_at": "2026-09-06T18:57:58Z"
"generated_at": "2026-09-06T22:00:48Z"
}

View File

@@ -28,7 +28,7 @@ Le projet doit implémenter les fonctionnalités suivantes, dans l'ordre logique
- Conservation des événements annulés avec `STATUS:CANCELLED`.
3. **Comparaison avec l'agenda théorique** :
- Détection des **changements** (ajouts, suppressions, modifications) entre l'agenda réel (Pronote) et un agenda théorique (fichier iCal/CSV local).
- Détection des **changements** (ajouts, suppressions, modifications) entre l'agenda réel (Pronote) et un agenda théorique (fichier JSON local, avec parité des semaines et vacances scolaires).
- Génération d'une liste structurée des différences.
4. **Génération de la synthèse** :
@@ -206,7 +206,7 @@ pronote_sync/
│ │ └── state.py # État local (déduplication, cache HTTP)
│ └── theoretical/ # Agenda théorique
│ ├── __init__.py
│ ├── file.py # Lecture fichier iCal/CSV
│ ├── file.py # Lecture fichier JSON (parité + vacances)
│ └── provider.py # Interface TheoreticalAgendaProvider
├── sync/ # Synchronisation CalDAV + Blog
│ ├── __init__.py
@@ -306,7 +306,10 @@ d'un besoin réel et testé.
> ⚠️ **Décision d'implémentation** :
> Ces variables sont désormais dans `AppSettings` (et non `CalDAVSettings`) car `CalDAVSettings` utilise `env_prefix="CALDAV_"`, ce qui nécessiterait `CALDAV_SYNC_PAST_DAYS`.
> Leur placement dans `AppSettings` (sans préfixe) garantit un mappage correct avec `SYNC_PAST_DAYS` / `SYNC_FUTURE_DAYS`.
| `THEORETICAL_AGENDA_PATH` | Chemin vers le fichier iCal/CSV de l'agenda théorique. | `None` | `str \| None`|
| `THEORETICAL_AGENDA_PATH` | Chemin vers le fichier JSON de l'agenda théorique. | `None` | `str \| None`|
| `SCHOOL_HOLIDAYS_PATH` | Chemin vers le fichier JSON des vacances scolaires. | `None` | `str \| None`|
| `THEORETICAL_WEEK_ANCHOR_DATE` | Date de référence pour la parité des semaines (paire/impaire). | `None` | `date \| None`|
| `THEORETICAL_WEEK_ANCHOR_TYPE` | Parité de la semaine de référence (`even` ou `odd`). | `None` | `Literal["even", "odd"] \| None`|
| `AI_ENABLED` | Activer la synthèse IA. | `False` | `bool` |
| `AI_PROVIDER` | Fournisseur IA (`openai` ou `litellm`). | `openai` | `str` |
| `AI_BASE_URL` | URL de base pour l'API IA (ex: OpenAI compatible). | `None` | `str \| None`|
@@ -341,8 +344,11 @@ CALDAV_CALENDAR_PATH=/pronote-sync/
SYNC_PAST_DAYS=7
SYNC_FUTURE_DAYS=30
# --- Agenda théorique ---
THEORETICAL_AGENDA_PATH=./data/theoretical.ics
# --- Agenda théorique (JSON) ---
THEORETICAL_AGENDA_PATH=./data/theoretical.json
SCHOOL_HOLIDAYS_PATH=./data/school_holidays.json
THEORETICAL_WEEK_ANCHOR_DATE=2026-09-01
THEORETICAL_WEEK_ANCHOR_TYPE=even
# --- XMPP ---
XMPP_JID=user@example.com
@@ -2706,14 +2712,95 @@ class SynthesisResult(BaseModel):
### 7.1 Principes
- **Synchronisation différentielle** : Ne pas écraser le calendrier distant, mais **mettre à jour uniquement les événements modifiés** (décision [3](#3-synchronisation-caldav---différentielle-avec-uid-stables)).
- **UID stables** : Utiliser des UID normalisés pour éviter les doublons.
- **Canonicalisation à la frontière des sources** : les UID sont normalisés une seule fois à la frontière des sources via `utils.uid.normalize_pronote_uid`, afin qu'un même cours provenant d'iCal ou de `pronotepy` produise le **même identifiant canonique** (aucun doublon ni suppression/ajout artificiel lors d'un changement de source).
- **Fenêtre de synchronisation** : Configurable via `SYNC_PAST_DAYS` et `SYNC_FUTURE_DAYS`.
- **Mode dry-run** : Obligatoire pour tester sans modifier le calendrier distant.
- **Idempotence** : Deux exécutions identiques **doivent** produire le même état CalDAV.
- **Scan du calendrier distant** : La synchronisation repose sur le **scan du calendrier CalDAV distant** (les événements gérés sont relus à chaque exécution) — **aucun état local** n'est conservé (voir §7.3).
- **Plan explicite** : Le `CalDAVSyncPlan` (ajouts / mises à jour / suppressions) est **calculé explicitement avant l'exécution** de la synchronisation (voir §7.2).
- **Événements non gérés** : Les événements **non marqués** `X-PRONOTE-SYNC-MANAGED: v1` **ne sont jamais modifiés ni supprimés** : ils appartiennent à d'autres outils ou à l'utilisateur.
### 7.2 Client CalDAV (`sync/caldav.py`)
Utilisation de la bibliothèque [`caldav`](https://pypi.org/project/caldav/) (Python 3.8+, maintenue).
> **⚠️ Code illustratif** : le bloc de code ci-dessous est **illustratif** : il montre les
> règles métier de la synchronisation (marqueur, comparaison, cours annulés, dry-run).
> L'**implémentation réelle** doit s'adapter à la version de la bibliothèque `caldav`
> installée (`caldav>=1.3.0`) : l'**API réelle** documentée ci-dessous **prévaut** sur les
> anciens appels encore présents dans l'exemple (ex: `calendar(name=...)`,
> `calendar.add_event(...)`, `event.properties`, `vobject_instance`).
#### API réelle (`caldav>=1.3.0`)
- **Connexion** : `caldav.DAVClient(url, username, password)` — les paramètres proviennent
de `CalDAVSettings` (`CALDAV_URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`).
- **Résolution du calendrier** : `DAVClient.principal()` puis `principal.calendars()` ;
sélectionner le calendrier dont l'URL correspond à **`CalDAVSettings.calendar_path`**
(ex: `/pronote-sync/`). La résolution ne se fait **pas** par nom de calendrier :
`calendar_name` est abandonné au profit de `calendar_path`.
- **Lecture des événements** : `calendar.objects()` liste les objets du calendrier
(`calendar.date_search(start=..., end=...)` reste utilisable pour une fenêtre selon la
version installée).
- **Contenu iCalendar** : chaque objet expose `event.icalendar_component` (un
`icalendar.Event`) donnant accès aux propriétés (`uid`, `summary`, `dtstart`, `dtend`,
`status`, `categories`, `X-PRONOTE-SYNC-MANAGED`).
- **Ajout** : `calendar.save_event(ical_text)` crée un événement (UID normalisé et
marqueur inclus).
- **Mise à jour** : modifier les propriétés de l'`icalendar_component` puis
`event.save()` (si la version installée le supporte), sinon supprimer puis recréer sur
le même UID via `save_event()`.
- **Suppression** : `event.delete()` — **uniquement** pour les événements marqués.
**Règles métier conservées** (indépendantes de la version de `caldav`) :
- **Marqueur** : chaque événement géré porte `X-PRONOTE-SYNC-MANAGED: v1`.
- **Comparaison `_events_equal`** : ne compare que les **champs gérés** (UID, DTSTART,
DTEND, SUMMARY, DESCRIPTION, STATUS, CATEGORIES, marqueur) et ignore les propriétés
**volatiles** (`DTSTAMP`, `CREATED`, `LAST-MODIFIED`) qui changent à chaque écriture
côté serveur.
- **Cours annulés** : conservés avec `STATUS:CANCELLED` (ne pas supprimer).
- **Mode dry-run** : logue le plan sans écrire sur le calendrier distant.
#### Approche en trois phases
1. **Collecte / scan** : lister les événements du calendrier distant (fenêtre
`SYNC_PAST_DAYS` / `SYNC_FUTURE_DAYS`) et ne retenir que les événements **gérés**
(`X-PRONOTE-SYNC-MANAGED: v1`), indexés par UID normalisé (`normalize_pronote_uid`).
2. **Calcul du plan** : comparer (via `_events_equal`) les événements distants gérés avec
les données Pronote (cours, devoirs, événements scolaires) et produire un
**`CalDAVSyncPlan` explicite** (`lessons_to_add`, `lessons_to_update`,
`lessons_to_remove`, etc.) — **aucune écriture** à ce stade.
3. **Exécution** : appliquer le plan (ajouts via `calendar.save_event()`, mises à jour si
les événements diffèrent, suppressions si l'UID est absent des données Pronote) ; en
mode `dry_run`, loguer le plan **sans rien écrire**.
Exemple minimal (API réelle) :
```python
import caldav
# CalDAVSettings : url, username, password (SecretStr), calendar_path = "/pronote-sync/"
settings = None # instance de CalDAVSettings (pydantic-settings)
client = caldav.DAVClient(
url=settings.url,
username=settings.username,
password=settings.password.get_secret_value(),
)
principal = client.principal()
calendar = next(
c for c in principal.calendars()
if str(c.url).rstrip("/").endswith(settings.calendar_path.rstrip("/"))
)
for obj in calendar.objects():
vevent = obj.icalendar_component # icalendar.Event
if vevent.get("X-PRONOTE-SYNC-MANAGED") == "v1":
print(vevent.get("uid"))
```
**Exemple illustratif** (règles métier complètes — API partiellement ancienne) :
```python
from typing import List, Optional, Dict, Any
from datetime import datetime, timedelta
@@ -2742,13 +2829,13 @@ class CalDAVClient:
url: str,
username: str,
password: str,
calendar_name: str = "Pronote",
calendar_path: str = "/pronote-sync/",
dry_run: bool = False,
):
self.url = url
self.username = username
self.password = password
self.calendar_name = calendar_name
self.calendar_path = calendar_path
self.dry_run = dry_run
self._client: Optional[caldav.DAVClient] = None
self._calendar: Optional[DAVCalendar] = None
@@ -2761,40 +2848,39 @@ class CalDAVClient:
password=self.password,
)
# Récupérer ou créer le calendrier
try:
self._calendar = self._client.calendar(name=self.calendar_name)
except caldav.lib.error.NotFoundError:
# Créer le calendrier s'il n'existe pas
if not self.dry_run:
self._calendar = self._client.make_calendar(
name=self.calendar_name,
supported_calendar_components=["VEVENT"],
)
# Résoudre le calendrier via calendar_path (cf. CalDAVSettings) :
# API réelle (caldav>=1.3.0) : principal.calendars() puis correspondance
# sur l'URL du calendrier.
principal = self._client.principal()
matches = [
c for c in principal.calendars()
if str(c.url).rstrip("/").endswith(self.calendar_path.rstrip("/"))
]
if matches:
self._calendar = matches[0]
else:
# Pas de création automatique : la résolution se fait par chemin uniquement.
logger.warning(
f"Calendrier {self.calendar_name} introuvable et dry_run activé. "
"Aucune modification ne sera effectuée."
f"Calendrier {self.calendar_path} introuvable"
f"{' et dry_run activé. Aucune modification ne sera effectuée.' if self.dry_run else ' : vérifier CalDAVSettings.calendar_path.'}"
)
# Créer un calendrier fictif pour les tests
self._calendar = None
def _is_managed_event(self, event: DAVEvent) -> bool:
"""Vérifie si un événement est géré par l'outil."""
# Vérifier la présence du marqueur X-PRONOTE-SYNC-MANAGED
props = event.properties
managed = props.get(self.MANAGED_PROPERTY, None)
return managed and managed.value == self.MANAGED_VALUE
# API réelle : event.icalendar_component (icalendar.Event)
vevent = event.icalendar_component
managed = vevent.get(self.MANAGED_PROPERTY)
return managed is not None and str(managed) == self.MANAGED_VALUE
def _get_event_uid(self, event: DAVEvent) -> str:
"""Récupère l'UID normalisé d'un événement."""
uid = event.vobject_instance.uid.value
uid = str(event.icalendar_component.get("uid"))
return normalize_pronote_uid(uid)
def _build_event(
self,
lesson: Lesson,
calendar_name: Optional[str] = None,
) -> DAVEvent:
"""Construit un événement CalDAV à partir d'un cours Pronote."""
from icalendar import Event, vDatetime, vDate, vText, vUri
@@ -2836,10 +2922,6 @@ class CalDAVClient:
categories.append("Déplacé")
event.add("categories", categories)
# Nom du calendrier (si disponible)
if calendar_name:
event.add("x-wr-calname", calendar_name)
return DAVEvent(event)
def _build_homework_event(self, homework: Homework) -> DAVEvent:
@@ -2895,7 +2977,12 @@ class CalDAVClient:
"""
Compare les champs gérés pour déterminer si une mise à jour est nécessaire.
Seuls les champs explicitement gérés par l'outil sont comparés :
UID, DTSTART, DTEND, SUMMARY, DESCRIPTION, STATUS, CATEGORIES, et X-PRONOTE-SYNC-MANAGED.
UID, DTSTART, DTEND, SUMMARY, DESCRIPTION, STATUS, CATEGORIES, et
X-PRONOTE-SYNC-MANAGED.
Les propriétés volatiles (DTSTAMP, CREATED, LAST-MODIFIED) sont
volontairement **exclues** de la comparaison : elles sont modifiées par le
serveur à chaque écriture et ne reflètent aucun changement Pronote.
Args:
event1: Événement existant dans CalDAV.
@@ -2905,19 +2992,17 @@ class CalDAVClient:
True si les événements sont identiques pour les champs gérés, False sinon.
"""
# Comparaison des UID normalisés
uid1 = self._get_event_uid(event1)
uid2 = self._get_event_uid(event2)
if uid1 != uid2:
if self._get_event_uid(event1) != self._get_event_uid(event2):
return False
# Comparaison des champs gérés
vobj1 = event1.vobject_instance
vobj2 = event2.vobject_instance
# Comparaison des champs gérés (API réelle : icalendar_component)
vobj1 = event1.icalendar_component
vobj2 = event2.icalendar_component
# DTSTART et DTEND
if vobj1.get("dtstart").value != vobj2.get("dtstart").value:
if vobj1.get("dtstart").dt != vobj2.get("dtstart").dt:
return False
if vobj1.get("dtend").value != vobj2.get("dtend").value:
if vobj1.get("dtend").dt != vobj2.get("dtend").dt:
return False
# SUMMARY
@@ -2933,8 +3018,8 @@ class CalDAVClient:
return False
# CATEGORIES (comparaison des listes)
cats1 = [str(c) for c in vobj1.get("categories", []).cats] if hasattr(vobj1.get("categories", None), "cats") else []
cats2 = [str(c) for c in vobj2.get("categories", []).cats] if hasattr(vobj2.get("categories", None), "cats") else []
cats1 = [str(c) for c in vobj1.get("categories", [])]
cats2 = [str(c) for c in vobj2.get("categories", [])]
if sorted(cats1) != sorted(cats2):
return False
@@ -3172,138 +3257,25 @@ class CalDAVClient:
self._calendar = None
```
### 7.3 État de synchronisation (`sync/state.py`)
### 7.3 État de synchronisation
Pour éviter de synchroniser à chaque exécution tous les événements depuis le début des temps, on peut stocker un **état local** de la synchronisation.
**M7 ne stocke aucun état local** : il n'existe ni fichier d'état ni module
`sync/state.py`.
**Protéger les fichiers d'état** :
- **Permissions** : Appliquer `chmod 600` sur les fichiers d'état (ex: `.pronote_sync_state.json`) pour limiter l'accès au propriétaire.
- **Exclusion Git** : Ajouter les fichiers d'état au `.gitignore` pour éviter de les commiter.
- **Exclusion des sauvegardes** : Exclure les fichiers dtat des sauvegardes automatiques (ex: Time Machine, rsync).
- **Emplacement** : Stocker les fichiers d'état dans un répertoire dédié (ex: `~/.config/pronote-sync/`) hors de l'arborescence Git.
- **Scan du calendrier distant** : à chaque exécution, la synchronisation **scanne le
calendrier CalDAV distant** pour retrouver les événements gérés (marqueur
`X-PRONOTE-SYNC-MANAGED: v1`), indexés par UID normalisé.
- **Le calendrier distant est la source de vérité** : la comparaison entre événements
distants gérés et données Pronote se fait directement sur le calendrier, sans fichier
intermédiaire. Cela garantit une **idempotence naturelle** (deux exécutions identiques
produisent le même état) et supprime les risques liés à un fichier local (corruption,
perte, fuite de données, permissions `chmod 600`, exclusion `.gitignore` ou des
sauvegardes).
#### 7.3.1 Options pour l'état local
| **Option** | **Avantages** | **Inconvénients** | **Recommandation** |
|------------------|----------------------------------------|---------------------------------------|-----------------------------|
| Fichier JSON | Simple, portable, pas de dépendance | Moins performant pour les gros volumes | ✅ Pour un usage simple |
| SQLite | Performant, requêtes complexes | Dépendance supplémentaire | ✅ Pour un usage avancé |
| Sync-token CalDAV| Natif, optimisé | Pas toujours supporté par les serveurs | ⚠️ Si disponible |
#### 7.3.2 Implémentation avec JSON (`sync/state.py`)
```python
import json
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional, Any
from ..models.agenda import Lesson, Homework
import logging
logger = logging.getLogger(__name__)
class SyncState:
"""
Gère l'état de synchronisation local (fichier JSON).
Stocke les UID et les timestamps des dernières synchronisations.
"""
def __init__(self, state_file: str = ".pronote_sync_state.json"):
self.state_file = Path(state_file)
self._state: Dict[str, Any] = {
"last_sync": None,
"synced_uids": {
"lessons": set(),
"homeworks": set(),
"school_events": set(),
},
"sync_history": [],
}
self._load()
def _load(self) -> None:
"""Charge l'état depuis le fichier."""
if self.state_file.exists():
try:
with open(self.state_file, "r", encoding="utf-8") as f:
self._state = json.load(f)
# Convertir les sets en sets (JSON les stocke comme des listes)
self._state["synced_uids"] = {
k: set(v) for k, v in self._state.get("synced_uids", {}).items()
}
except Exception as e:
logger.warning(f"Échec du chargement de l'état: {e}")
self._state = {
"last_sync": None,
"synced_uids": {
"lessons": set(),
"homeworks": set(),
"school_events": set(),
},
"sync_history": [],
}
def _save(self) -> None:
"""Sauvegarde l'état dans le fichier."""
# Convertir les sets en listes pour JSON
state_to_save = {
**self._state,
"synced_uids": {
k: list(v) for k, v in self._state["synced_uids"].items()
},
}
try:
with open(self.state_file, "w", encoding="utf-8") as f:
json.dump(state_to_save, f, indent=2, ensure_ascii=False)
except Exception as e:
logger.error(f"Échec de la sauvegarde de l'état: {e}")
def mark_synced(
self,
lessons: list[Lesson],
homeworks: list[Homework],
school_events: list,
) -> None:
"""Marque les UID comme synchronisés."""
self._state["synced_uids"]["lessons"].update(lesson.id for lesson in lessons)
self._state["synced_uids"]["homeworks"].update(
f"homework-{hw.id}" for hw in homeworks
)
self._state["synced_uids"]["school_events"].update(
f"school-event-{se.label}-{se.from_date.isoformat()}" for se in school_events
)
self._state["last_sync"] = datetime.now().isoformat()
self._state["sync_history"].append({
"timestamp": datetime.now().isoformat(),
"lessons": len(lessons),
"homeworks": len(homeworks),
"school_events": len(school_events),
})
self._save()
def is_synced(self, uid: str, kind: str = "lessons") -> bool:
"""Vérifie si un UID a déjà été synchronisé."""
return uid in self._state["synced_uids"].get(kind, set())
def get_last_sync(self) -> Optional[datetime]:
"""Récupère la date de la dernière synchronisation."""
if self._state["last_sync"]:
return datetime.fromisoformat(self._state["last_sync"])
return None
def clear(self) -> None:
"""Efface l'état."""
self._state = {
"last_sync": None,
"synced_uids": {
"lessons": set(),
"homeworks": set(),
"school_events": set(),
},
"sync_history": [],
}
self._save()
```
> **Évolution future** : si les performances l'exigent (calendrier très chargé, scans
> trop coûteux), un **état local** (fichier JSON, SQLite ou sync-token CalDAV) pourra
> être ajouté dans un jalon ultérieur, sans changer le contrat de la synchronisation
> (§7.1, §7.2 et §7.4 restent valables).
### 7.4 Points clés
- **Différentielle** : La synchronisation compare les UID existants avec ceux à synchroniser.
@@ -3311,6 +3283,9 @@ class SyncState:
- **Dry-run** : Mode obligatoire pour tester sans effet de bord.
- **Marquage** : Les événements gérés sont marqués avec `X-PRONOTE-SYNC-MANAGED: v1` pour éviter les conflits.
- **Cours annulés** : Conservés avec `STATUS:CANCELLED` (ne pas supprimer).
- **Plan explicite** : Le `CalDAVSyncPlan` est calculé avant l'exécution.
- **Pas d'état local** : La comparaison se fait avec le calendrier distant (scan).
- **Événements non gérés** : Les événements non marqués ne sont jamais modifiés ni supprimés.
---
@@ -3320,6 +3295,10 @@ class SyncState:
- **Agenda théorique** : Représente l'emploi du temps **attendu** (ex: emploi du temps officiel de l'établissement).
- **Agenda réel** : Représente l'emploi du temps **réel** (récupéré depuis Pronote).
- **Objectif** : Détecter les **changements** (ajouts, suppressions, modifications) entre les deux.
- **Format JSON** : L'agenda théorique est désormais décrit par un **fichier JSON** (et non plus iCal/CSV), avec gestion de la **parité des semaines** (paire/impaire) et des **vacances scolaires**.
- **Parité des semaines** : Chaque leçon peut s'appliquer à **toutes** les semaines (`all`), uniquement aux semaines **paires** (`even`) ou **impaires** (`odd`). La parité d'une date est calculée par rapport à une **date de référence** configurée.
- **Vacances scolaires** : Un **fichier JSON séparé** liste les périodes de vacances (ex: zone A) ; aucune leçon théorique n'est produite pendant ces périodes.
- **Encapsulation** : Le provider encapsule **en interne** le calcul de la parité et le filtrage des vacances ; l'appelant (ex: `AgendaComparator`) reçoit simplement les cours théoriques ou une liste vide.
- **Matching déterministe** : Utiliser des règles claires pour associer un cours réel à un cours théorique (décision [4](#4-agenda-théorique---interface-abstraite--implémentation-fichier)).
### 8.2 Interface `TheoreticalAgendaProvider` (`sources/theoretical/provider.py`)
@@ -3367,192 +3346,135 @@ class TheoreticalAgendaProvider(Protocol):
```
> **Note sur le périmètre du provider** : Le fournisseur gère **en interne** la **parité des semaines** (paire/impaire) et les **vacances scolaires**. L'appelant ne connaît ni la date de référence de parité, ni les périodes de vacances : en période de vacances (ou pour une semaine dont la parité ne correspond à aucune leçon), il reçoit simplement une **liste vide**. Le contrat `TheoreticalAgendaProvider` reste donc volontairement minimal et stable.
### 8.3 Implémentation par fichier (`sources/theoretical/file.py`)
#### 8.3.1 Fichier iCal
#### 8.3.1 Format du fichier JSON de l'agenda théorique
Si l'agenda théorique est fourni sous forme de **fichier iCal** (ex: export depuis un autre outil), on peut le parser de la même manière que le flux Pronote.
L'agenda théorique est fourni sous forme de **fichier JSON** (ex: `./data/theoretical.json`) :
```python
from typing import List
from datetime import date, time
from pathlib import Path
from icalendar import Calendar, Event
from ..models.agenda import TheoreticalLesson
from .provider import TheoreticalAgendaProvider
class ICalTheoreticalAgendaProvider:
"""Fournisseur d'agenda théorique depuis un fichier iCal."""
def __init__(self, file_path: str):
self.file_path = Path(file_path)
self._lessons: List[TheoreticalLesson] = []
self._load()
def _load(self) -> None:
"""Charge le fichier iCal et parse les cours."""
if not self.file_path.exists():
raise FileNotFoundError(f"Fichier iCal introuvable: {self.file_path}")
with open(self.file_path, "rb") as f:
cal = Calendar.from_ical(f.read())
for component in cal.walk():
if not isinstance(component, Event):
continue
# Ignorer les événements tout le jour (vacances, etc.)
if hasattr(component.get("dtstart"), "dt") and not hasattr(component.get("dtstart").dt, "hour"):
continue
start = component.get("dtstart").dt
end = component.get("dtend").dt
# Générer un ID stable (basé sur le jour, l'heure et la matière)
summary = str(component.get("summary", ""))
uid = f"theoretical-{start.strftime('%Y%m%d')}-{start.hour}{start.minute}-{summary}"
lesson = TheoreticalLesson(
id=uid,
day_of_week=start.weekday(),
start_time=time(start.hour, start.minute),
end_time=time(end.hour, end.minute),
subject=summary,
teachers=[], # À extraire de la description si disponible
rooms=[],
)
self._lessons.append(lesson)
def get_lessons(self, date: date) -> List[TheoreticalLesson]:
"""Récupère les cours pour une date donnée."""
day_of_week = date.weekday()
return [
lesson for lesson in self._lessons
if lesson.day_of_week == day_of_week
]
def get_lessons_for_range(
self,
start_date: date,
end_date: date,
) -> List[TheoreticalLesson]:
"""Récupère les cours pour une plage de dates."""
from datetime import timedelta
result = []
current_date = start_date
while current_date <= end_date:
result.extend(self.get_lessons(current_date))
current_date += timedelta(days=1)
return result
#### 8.3.2 Fichier CSV
Si l'agenda théorique est fourni sous forme de **fichier CSV**, on peut le parser ainsi :
```csv
jour,semaine,heure_debut,heure_fin,matiere,professeur,salle
lundi,1,08:00,09:00,Mathématiques,M. Dupont,204
lundi,1,09:00,10:00,Français,Mme Martin,205
...
```
```python
import csv
from typing import List
from datetime import date, time
from pathlib import Path
from ..models.agenda import TheoreticalLesson
from .provider import TheoreticalAgendaProvider
class CSVTheoreticalAgendaProvider:
"""Fournisseur d'agenda théorique depuis un fichier CSV."""
def __init__(self, file_path: str):
self.file_path = Path(file_path)
self._lessons: List[TheoreticalLesson] = []
self._load()
def _load(self) -> None:
"""Charge le fichier CSV et parse les cours."""
if not self.file_path.exists():
raise FileNotFoundError(f"Fichier CSV introuvable: {self.file_path}")
with open(self.file_path, "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
day_of_week = self._parse_day(row["jour"])
start_time = self._parse_time(row["heure_debut"])
end_time = self._parse_time(row["heure_fin"])
# Générer un ID stable
uid = f"theoretical-{day_of_week}-{start_time.isoformat()}-{row['matiere']}"
lesson = TheoreticalLesson(
id=uid,
day_of_week=day_of_week,
start_time=start_time,
end_time=end_time,
subject=row["matiere"],
teachers=[row["professeur"]] if row.get("professeur") else [],
rooms=[row["salle"]] if row.get("salle") else [],
)
self._lessons.append(lesson)
def _parse_day(self, day: str) -> int:
"""Convertit un nom de jour en index (0=lundi, 6=dimanche)."""
days = {
"lundi": 0,
"mardi": 1,
"mercredi": 2,
"jeudi": 3,
"vendredi": 4,
"samedi": 5,
"dimanche": 6,
```json
{
"version": 1,
"lessons": [
{
"week": "all",
"day_of_week": 0,
"start_time": "08:00",
"end_time": "09:00",
"subject": "Mathématiques",
"teachers": ["M. Dupont"],
"rooms": ["101"]
},
{
"week": "even",
"day_of_week": 1,
"start_time": "10:00",
"end_time": "11:00",
"subject": "Anglais",
"teachers": [],
"rooms": []
},
{
"week": "odd",
"day_of_week": 1,
"start_time": "10:00",
"end_time": "11:00",
"subject": "Espagnol",
"teachers": [],
"rooms": []
}
return days.get(day.lower(), 0)
def _parse_time(self, time_str: str) -> time:
"""Parse une chaîne de temps (ex: 08:00)."""
hour, minute = map(int, time_str.split(":"))
return time(hour, minute)
def get_lessons(self, date: date) -> List[TheoreticalLesson]:
"""Récupère les cours pour une date donnée."""
day_of_week = date.weekday()
return [
lesson for lesson in self._lessons
if lesson.day_of_week == day_of_week
]
def get_lessons_for_range(
self,
start_date: date,
end_date: date,
) -> List[TheoreticalLesson]:
"""Récupère les cours pour une plage de dates."""
from datetime import timedelta
result = []
current_date = start_date
while current_date <= end_date:
result.extend(self.get_lessons(current_date))
current_date += timedelta(days=1)
return result
}
```
- `week` : `"all"` (toutes les semaines), `"even"` (semaines paires) ou `"odd"` (semaines impaires).
- `day_of_week` : entier de **0 (lundi)** à **6 (dimanche)**.
- `start_time` / `end_time` : chaînes au format `"HH:MM"`.
- `teachers` / `rooms` : listes de chaînes, **optionnelles** (défaut : liste vide).
- `id` : **optionnel** ; s'il est absent, le provider génère un identifiant **déterministe incluant le type de semaine**, afin que deux leçons de parité différente sur le même créneau aient des identifiants distincts.
#### 8.3.2 Format du fichier JSON des vacances scolaires (fichier séparé)
Les vacances scolaires sont décrites dans un **fichier JSON séparé** (ex: `./data/school_holidays.json`) :
```json
{
"zone": "A",
"school_year": "2026-2027",
"periods": [
{
"start_date": "2026-10-17",
"end_date": "2026-11-02",
"label": "Toussaint"
}
]
}
```
- `start_date` et `end_date` sont des dates ISO (`YYYY-MM-DD`) **inclusives**.
- `is_holiday(date)` retourne `True` si la date tombe dans **l'une** des périodes (`start_date <= date <= end_date`).
#### 8.3.3 Service de parité de semaine
La parité des semaines est configurée via deux paramètres :
- `THEORETICAL_WEEK_ANCHOR_DATE` : date de référence (ex: `2026-09-01`).
- `THEORETICAL_WEEK_ANCHOR_TYPE` : `"even"` ou `"odd"` (parité de la semaine de référence).
**Algorithme** : on calcule le nombre de semaines entre le **lundi de la semaine cible** et le **lundi de la semaine de référence**. Si ce décalage est **pair**, la semaine cible a la **même parité** que l'ancre ; s'il est **impair**, la parité est **opposée**.
```python
from datetime import date, timedelta
from typing import Literal
def week_parity(
target: date,
anchor_date: date,
anchor_type: Literal["even", "odd"],
) -> Literal["even", "odd"]:
"""Détermine la parité (paire/impaire) de la semaine d'une date cible.
:param target: Date dont on veut connaître la parité de semaine.
:param anchor_date: Date de référence (semaine de parité ``anchor_type``).
:param anchor_type: Parité de la semaine de référence (``"even"`` ou ``"odd"``).
:return: ``"even"`` ou ``"odd"`` selon la parité calculée.
:rtype: Literal["even", "odd"]
"""
target_monday = target - timedelta(days=target.weekday())
anchor_monday = anchor_date - timedelta(days=anchor_date.weekday())
offset_weeks = (target_monday - anchor_monday).days // 7
if offset_weeks % 2 == 0:
return anchor_type
return "odd" if anchor_type == "even" else "even"
```
#### 8.3.4 Comportement du provider JSON
- `get_lessons(date)` : si la date tombe pendant les **vacances scolaires**, retourner `[]`. Sinon, déterminer la **parité de la semaine**, filtrer les leçons selon le champ `week` (`all` correspond à toutes les semaines, `even`/`odd` à leur parité respective), construire les objets `TheoreticalLesson` et retourner la liste **triée par `id`**.
- `get_lessons_for_range(start_date, end_date)` : itérer sur **chaque date** de la plage, ignorer les **vacances scolaires**, appliquer le **filtrage de parité** à chaque jour et retourner la liste cumulée (éventuellement **dédupliquée par `id`**).
#### 8.3.5 Configuration
- `THEORETICAL_AGENDA_PATH` : chemin vers le fichier JSON de l'agenda théorique (ex: `./data/theoretical.json`).
- `SCHOOL_HOLIDAYS_PATH` : chemin vers le fichier JSON des vacances scolaires.
- `THEORETICAL_WEEK_ANCHOR_DATE` : date de référence pour la parité (ex: `2026-09-01`).
- `THEORETICAL_WEEK_ANCHOR_TYPE` : `"even"` ou `"odd"`.
- Si `THEORETICAL_AGENDA_PATH` est `None`, le provider est **désactivé** (M8 retourne un diff vide, non bloquant).
- Si le fichier d'agenda contient des leçons `even`/`odd` mais **aucune ancre n'est configurée**, lever une **erreur de configuration** explicite.
### 8.4 Politique de départage pour les collisions
**Règle déterministe** pour les collisions entre cours théoriques et réels :
1. **Tri par identifiant stable** : Les cours sont triés par UID ou clé de matching (ex: `theoretical-{day_of_week}-{start_time}-{subject}`).
1. **Tri par identifiant stable** : Les cours sont triés par ID ou clé de matching (ex: `theoretical-{day_of_week}-{start_time}-{subject}`).
2. **Comparaison exacte** : Les créneaux horaires et la matière normalisée doivent correspondre.
3. **Choix de la première correspondance** : En cas de multiples correspondances admissibles, choisir la **première** après tri déterministe.
**Exemple de tri** :
**Exemple de tri** :
```python
# Tri des cours théoriques par ID stable (pour un matching déterministe)
theoretical_lessons_sorted = sorted(
@@ -3564,25 +3486,44 @@ theoretical_lessons_sorted = sorted(
lesson.subject.lower(),
),
)
```
**Exemple de matching avec départage déterministe** :
```python
def match_theoretical_event(
real_lesson: PronoteLesson,
theoretical_events: list[TheoreticalEvent],
def match_theoretical_lesson(
real_lesson: Lesson,
theoretical_events: list[TheoreticalLesson],
tolerance_minutes: int = 15,
) -> TheoreticalEvent | None:
"""Trouve l'événement théorique correspondant, avec départage déterministe."""
) -> TheoreticalLesson | None:
"""Trouve la leçon théorique correspondant à une leçon réelle.
:param real_lesson: Leçon réelle depuis Pronote.
:param theoretical_events: Liste des leçons théoriques candidates.
:param tolerance_minutes: Tolérance en minutes pour le créneau horaire.
:return: La leçon théorique correspondante, ou None.
:rtype: TheoreticalLesson | None
"""
real_start = real_lesson.start
real_day = real_start.weekday()
def to_minutes(t: time) -> int:
return t.hour * 60 + t.minute
# ``normalize_subject`` sera défini dans ``sync/diff.py`` (M8) ou dans le
# module théorique ; il normalise les matières pour un matching déterministe.
start_minutes = real_start.hour * 60 + real_start.minute
end_minutes = real_lesson.end.hour * 60 + real_lesson.end.minute
candidates = [
t for t in theoretical_events
if abs((t.start - real_lesson.start).total_seconds()) <= tolerance_minutes * 60
t
for t in theoretical_events
if t.day_of_week == real_day
and abs(to_minutes(t.start_time) - start_minutes) <= tolerance_minutes
and abs(to_minutes(t.end_time) - end_minutes) <= tolerance_minutes
and normalize_subject(t.subject) == normalize_subject(real_lesson.subject)
and t.start.date() == real_lesson.start.date()
]
if not candidates:
return None
# Tri déterministe par UID stable, puis par créneau
candidates.sort(key=lambda t: (t.uid or "", t.start))
candidates.sort(key=lambda t: (t.id, t.start_time))
return candidates[0]
```
@@ -3789,11 +3730,14 @@ class AgendaComparator:
### 8.7 Points clés
- **Format JSON** : L'agenda théorique est décrit par un **fichier JSON** (leçons `all`/`even`/`odd`) ; les vacances scolaires sont décrites par un **fichier JSON séparé**.
- **Parité des semaines** : Déterminée par `THEORETICAL_WEEK_ANCHOR_DATE` et `THEORETICAL_WEEK_ANCHOR_TYPE` (décalage en semaines entre le lundi de référence et le lundi cible).
- **Vacances scolaires** : Les jours de vacances retournent une **liste vide** (aucune leçon théorique).
- **Identifiants déterministes** : Générés par le provider (type de semaine inclus) pour garantir des IDs distincts et stables.
- **Matching déterministe** : Basé sur le jour, le créneau horaire (avec tolérance) et la matière normalisée.
- **Normalisation** : Les matières et heures sont normalisées pour éviter les faux négatifs.
- **Types de changements** : Ajout, suppression, modification.
- **Agenda théorique** : Peut être fourni via fichier iCal ou CSV (extensible à d'autres sources).
- **Politique de départage** : Tri par identifiant stable (UID), puis comparaison exacte des créneaux et matière normalisée. En cas de multiples correspondances, choix de la première après tri déterministe.
- **Politique de départage** : Tri par identifiant stable (ID), puis comparaison exacte des créneaux et matière normalisée. En cas de multiples correspondances, choix de la première après tri déterministe.
---
@@ -4917,11 +4861,11 @@ Les autres étapes (`normalize_step`, `compare_step`, etc.) suivent le même pri
tests/
├── __init__.py
├── conftest.py # Fixtures pytest partagées
├── fixtures/ # Fichiers de fixtures (iCal, CSV, XML, etc.)
├── fixtures/ # Fichiers de fixtures (iCal, JSON, XML, etc.)
│ ├── pronote-4e.ics # Flux iCal Pronote anonymisé (4ème)
│ ├── pronote-6e.ics # Flux iCal Pronote anonymisé (6ème)
│ ├── theoretical.ics # Agenda théorique iCal
│ ├── theoretical.csv # Agenda théorique CSV
│ ├── theoretical.json # Agenda théorique JSON
│ ├── school_holidays.json # Vacances scolaires JSON
│ └── blog_rss.xml # Flux RSS du blog anonymisé
├── unit/ # Tests unitaires
│ ├── test_models.py # Tests des modèles Pydantic
@@ -4995,17 +4939,49 @@ END:VCALENDAR
- **Données réalistes** : Structure identique aux flux réels Pronote.
#### 12.3.2 Exemple de fichier CSV théorique (`tests/fixtures/theoretical.csv`)
#### 12.3.2 Exemple de fichier JSON théorique (`tests/fixtures/theoretical.json`)
```csv
jour,semaine,heure_debut,heure_fin,matiere,professeur,salle
lundi,1,08:00,09:00,Mathématiques,M. Dupont,204
lundi,1,09:00,10:00,Français,Mme Martin,205
lundi,1,10:00,11:00,Histoire-Géographie,M. Bernard,206
mardi,1,08:00,09:00,Physique-Chimie,Mme Durand,301
...
```json
{
"version": 1,
"lessons": [
{
"id": "theoretical-maths-monday-1",
"week": "all",
"day_of_week": 0,
"start_time": "08:00",
"end_time": "09:00",
"subject": "Mathématiques",
"teachers": ["Mme Martin"],
"rooms": ["101"]
},
{
"week": "all",
"day_of_week": 0,
"start_time": "09:00",
"end_time": "10:00",
"subject": "Français",
"teachers": ["M. Dupont"],
"rooms": ["102"]
},
{
"week": "even",
"day_of_week": 1,
"start_time": "10:00",
"end_time": "11:00",
"subject": "Anglais",
"teachers": ["Mme Bernard"],
"rooms": ["201"]
}
]
}
```
**Champs clés** :
- `week` : `all` (toutes les semaines), `even` (semaines paires) ou `odd` (semaines impaires).
- `day_of_week` : jour de la semaine (0 = lundi, 4 = vendredi).
- `start_time` / `end_time` : créneau horaire au format `HH:MM`.
### 12.4 Configuration pytest (`tests/conftest.py`)
@@ -5247,7 +5223,7 @@ def sample_settings():
app=AppSettings(
dry_run=True,
log_level="DEBUG",
theoretical_agenda_path="./tests/fixtures/theoretical.ics",
theoretical_agenda_path="./tests/fixtures/theoretical.json",
),
)
@@ -5298,7 +5274,7 @@ def test_pipeline_full(mock_requests_get, mock_caldav_client, mock_ai_provider,
from pronote_sync.sources.pronote.fallback import PronoteFetcher
from pronote_sync.sync.caldav import CalDAVClient
from pronote_sync.sync.diff import AgendaComparator
from pronote_sync.sources.theoretical.file import CSVTheoreticalAgendaProvider
from pronote_sync.sources.theoretical.file import JsonTheoreticalAgendaProvider
# Configurer le fetcher Pronote
pronote_client = PronoteClient(sample_settings.pronote)
@@ -5313,7 +5289,7 @@ def test_pipeline_full(mock_requests_get, mock_caldav_client, mock_ai_provider,
)
# Configurer le comparateur d'agenda
theoretical_provider = CSVTheoreticalAgendaProvider(
theoretical_provider = JsonTheoreticalAgendaProvider(
file_path=sample_settings.app.theoretical_agenda_path
)
comparator = AgendaComparator(theoretical_provider)

36
TODO.md
View File

@@ -112,16 +112,23 @@ Récupérer le flux RSS du blog du collège, parser et dédupliquer les articles
## M6. Source agenda théorique — Priorité : Moyenne
Lire l'agenda théorique (iCal ou CSV) via une interface de provider extensible.
Lire l'agenda théorique (JSON) via une interface de provider extensible, avec gestion de la parité des semaines (paire/impaire) et des vacances scolaires.
- [ ] Créer `sources/theoretical/provider.py` : protocole `TheoreticalAgendaProvider` (§8.2).
- [ ] Créer `sources/theoretical/file.py` : lecture fichier iCal/CSV → liste de `TheoreticalLesson` (§8.3).
- [ ] Normaliser les matières et créneaux pour le matching déterministe.
- [ ] Supporter les deux formats (iCal et CSV) derrière la même interface.
- [x] Créer `sources/theoretical/provider.py` : protocole `TheoreticalAgendaProvider` (§8.2).
- [x] Créer `sources/theoretical/file.py` : parser JSON → liste de `TheoreticalLesson` avec filtrage par parité de semaine (paire/impaire/toutes).
- [x] Créer `sources/theoretical/parity.py` : service `WeekParityService` déterminant la parité d'une date à partir d'une date de référence configurée.
- [x] Créer `sources/theoretical/holidays.py` : service `SchoolHolidayCalendar` lisant un fichier JSON de vacances scolaires (zone A) et exposant `is_holiday(date)`.
- [x] Implémenter le provider JSON : filtrage par parité + vacances, génération d'identifiants déterministes incluant le type de semaine.
- [x] Ajouter la configuration : `SCHOOL_HOLIDAYS_PATH`, `THEORETICAL_WEEK_ANCHOR_DATE`, `THEORETICAL_WEEK_ANCHOR_TYPE` dans `AppSettings`.
- [x] Normaliser les matières et créneaux pour le matching déterministe.
- [x] Créer les fixtures : `tests/fixtures/theoretical.json` et `tests/fixtures/school_holidays.json`.
### Critères d'acceptation
- `file.py` lit `tests/fixtures/theoretical.ics` et `theoretical.csv` en `TheoreticalLesson`.
- `file.py` lit `tests/fixtures/theoretical.json` en `TheoreticalLesson` avec filtrage par parité.
- Le provider renvoie une liste vide pendant les vacances scolaires.
- Le provider renvoie une liste stable et déterministe (tri par identifiant).
- Les identifiants sont distincts pour des leçons de parité différente sur le même créneau.
- Une configuration incomplète (ancre de parité manquante alors que des leçons `even`/`odd` existent) produit une erreur explicite.
---
@@ -129,19 +136,20 @@ Lire l'agenda théorique (iCal ou CSV) via une interface de provider extensible.
Synchroniser différentiellement les événements Pronote vers le calendrier CalDAV, de façon idempotente.
- [ ] Créer `sync/caldav.py` : `CalDAVClient` (connexion, liste/ajout/MAJ/suppression, marqueur `X-PRONOTE-SYNC-MANAGED: v1`).
- [ ] Créer `sync/state.py` : état local de sync (SQLite ou JSON) assurant l'idempotence (UID connus).
- [ ] Calculer le `CalDAVSyncPlan` (to_add / to_update / to_remove) par UID stable.
- [ ] Avant de figer le plan de sync, vérifier sur fixture anonymisée que le même cours provenant d'iCal et de `pronotepy` possède le même identifiant canonique ; corriger la normalisation à la frontière des sources si nécessaire.
- [ ] Créer `sync/caldav.py` : passerelle CalDAV isolant la bibliothèque `caldav>=1.3.0` (connexion via `DAVClient`, résolution du calendrier via `calendar_path`, récupération/ajout/MAJ/suppression des événements, marqueur `X-PRONOTE-SYNC-MANAGED: v1`).
- [ ] Calculer le `CalDAVSyncPlan` (to_add / to_update / to_remove) par UID stable, explicitement avant l'exécution de la sync.
- [ ] Implémenter l'exécution du plan : ajout, mise à jour (si modifié), suppression (si absent). En mode `dry_run`, loguer le plan sans écrire.
- [ ] Vérifier sur fixture anonymisée que le même cours provenant d'iCal et de `pronotepy` possède le même identifiant canonique ; corriger la normalisation des UID dans `sources/pronote/client.py` à la frontière des sources si nécessaire.
- [ ] Implémenter la sync différentielle : conserver les cours annulés (`STATUS:CANCELLED`), ne pas supprimer.
- [ ] Garantir l'idempotence (2 exécutions identiques → même `CalDAVSyncResult`).
- [ ] Réutiliser `BlogRSSState` pour l'état blog si pertinent (sinon `sync/blog_state.py`).
- [ ] Garantir l'idempotence (2 exécutions identiques → même `CalDAVSyncResult`), sans état local persistant (scan du calendrier distant).
- [ ] Ne jamais modifier ou supprimer les événements non marqués `X-PRONOTE-SYNC-MANAGED`.
### Critères d'acceptation
- Le plan de sync est correctement calculé (PronoteData vs état local).
- Le plan de sync est correctement calculé (données Pronote vs événements distants gérés).
- Un changement de source iCal ↔ `pronotepy` ne crée ni doublon ni suppression/ajout artificiel pour un cours équivalent.
- Un run dry-run n'écrit rien ; deux runs identiques donnent un résultat identique.
- Les événements annulés restent (`STATUS:CANCELLED`) et sont marqués `MANAGED`.
- Les événements non marqués ne sont jamais modifiés ni supprimés.
---
@@ -236,7 +244,7 @@ Exposer le lancement du pipeline via une interface en ligne de commande.
Couvrir l'ensemble du code par des tests sans réseau, avec fixtures anonymisées, jusqu'à ≥ 90 %.
- [ ] Créer `tests/fixtures/` : `pronote-4e.ics`, `pronote-6e.ics`, `theoretical.ics`, `theoretical.csv`, `blog_rss.xml` (anonymisés, sans `icalsecurise`).
- [ ] Créer `tests/fixtures/` : `pronote-4e.ics`, `pronote-6e.ics`, `theoretical.json`, `school_holidays.json`, `blog_rss.xml` (anonymisés, sans `icalsecurise`).
- [ ] Créer `tests/conftest.py` : fixtures partagées (sample_lesson, sample_cancelled_lesson, sample_homework, sample_school_event, sample_message, sample_pronote_data…).
- [ ] Écrire `tests/unit/` : `test_models`, `test_parsing` (iCal), `test_uid`, `test_redaction`, `test_diff`, `test_sync`.
- [ ] Couvrir les régressions M4 : signature réelle de `ParentClient`, ENT autorisé/inconnu, erreur vs résultat vide, `STATUS:CANCELLED` sans catégorie, plusieurs devoirs à la même date, filtrage `pronotepy` sur la date cible et stabilité d'identité entre sources.

View File

@@ -8,6 +8,7 @@ depuis les variables d'environnement (préfixées par groupe) et le fichier
from __future__ import annotations
from datetime import date
from typing import Literal
from pydantic import Field, SecretStr, field_serializer
@@ -114,7 +115,10 @@ class AppSettings(BaseSettings):
"""Paramètres généraux de l'application, sans préfixe d'environnement.
Contient notamment la fenêtre de synchronisation en jours
(``SYNC_PAST_DAYS`` / ``SYNC_FUTURE_DAYS``).
(``SYNC_PAST_DAYS`` / ``SYNC_FUTURE_DAYS``) et la configuration de
l'agenda théorique (``THEORETICAL_AGENDA_PATH``,
``THEORETICAL_WEEK_ANCHOR_DATE``, ``THEORETICAL_WEEK_ANCHOR_TYPE`` ainsi
que ``SCHOOL_HOLIDAYS_PATH`` pour les vacances scolaires).
"""
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@@ -122,6 +126,9 @@ class AppSettings(BaseSettings):
dry_run: bool = False
log_level: str = "INFO"
theoretical_agenda_path: str | None = None
school_holidays_path: str | None = None
theoretical_week_anchor_date: date | None = None
theoretical_week_anchor_type: Literal["even", "odd"] | None = None
sync_past_days: int = 7
sync_future_days: int = 30

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,204 @@
"""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
import re
import unicodedata
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 normalize_subject(subject: str) -> str:
"""Normalise une matière pour le matching déterministe.
Applique la normalisation Unicode NFKC, unifie les espaces (y compris
tabulations et espaces insécables), supprime la ponctuation et met la
chaîne en minuscules. Deux représentations visuellement identiques d'une
même matière produisent ainsi la même forme normalisée.
:param subject: La matière brute.
:return: La forme normalisée (NFKC, espaces unifiés, sans ponctuation, minuscule).
:rtype: str
"""
normalized = unicodedata.normalize("NFKC", subject)
normalized = re.sub(r"\s+", " ", normalized).strip()
normalized = re.sub(r"[^\w\s]", "", normalized)
normalized = re.sub(r"\s+", " ", normalized).strip()
return normalized.lower()
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 = normalize_subject(entry.subject).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 '{redact_secrets(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]
"""
...

26
tests/fixtures/school_holidays.json vendored Normal file
View File

@@ -0,0 +1,26 @@
{
"zone": "A",
"school_year": "2026-2027",
"periods": [
{
"start_date": "2026-10-17",
"end_date": "2026-11-02",
"label": "Toussaint"
},
{
"start_date": "2026-12-19",
"end_date": "2027-01-04",
"label": "Noël"
},
{
"start_date": "2027-02-06",
"end_date": "2027-02-22",
"label": "Hiver"
},
{
"start_date": "2027-04-03",
"end_date": "2027-04-19",
"label": "Printemps"
}
]
}

87
tests/fixtures/theoretical.json vendored Normal file
View File

@@ -0,0 +1,87 @@
{
"version": 1,
"lessons": [
{
"id": "theoretical-maths-monday-1",
"week": "all",
"day_of_week": 0,
"start_time": "08:00",
"end_time": "09:00",
"subject": "Mathématiques",
"teachers": ["Mme Martin"],
"rooms": ["101"]
},
{
"week": "all",
"day_of_week": 0,
"start_time": "09:00",
"end_time": "10:00",
"subject": "Français",
"teachers": ["M. Dupont"],
"rooms": ["102"]
},
{
"week": "all",
"day_of_week": 0,
"start_time": "11:00",
"end_time": "12:00",
"subject": "Histoire-Géographie",
"teachers": ["Mme Petit"],
"rooms": ["103"]
},
{
"week": "even",
"day_of_week": 1,
"start_time": "10:00",
"end_time": "11:00",
"subject": "Anglais",
"teachers": ["Mme Bernard"],
"rooms": ["201"]
},
{
"week": "even",
"day_of_week": 1,
"start_time": "10:00",
"end_time": "11:00",
"subject": "Technologie",
"teachers": [],
"rooms": []
},
{
"week": "odd",
"day_of_week": 1,
"start_time": "10:00",
"end_time": "11:00",
"subject": "Espagnol",
"teachers": ["M. Garcia"],
"rooms": ["202"]
},
{
"week": "odd",
"day_of_week": 1,
"start_time": "10:00",
"end_time": "11:00",
"subject": "Éducation musicale",
"teachers": [],
"rooms": []
},
{
"week": "all",
"day_of_week": 2,
"start_time": "14:00",
"end_time": "15:00",
"subject": "Sciences",
"teachers": [],
"rooms": ["203"]
},
{
"week": "all",
"day_of_week": 4,
"start_time": "09:00",
"end_time": "10:00",
"subject": "Arts plastiques",
"teachers": [],
"rooms": []
}
]
}

View File

@@ -0,0 +1,146 @@
"""Tests unitaires pour l'usine de construction du fournisseur d'agenda théorique.
Ce module contient les tests pour la fonction :func:`get_theoretical_provider`
du module :mod:`pronote_sync.sources.theoretical`.
"""
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
import pytest
from pronote_sync.errors import PronoteSyncError
from pronote_sync.sources.theoretical import (
TheoreticalAgendaProvider,
get_theoretical_provider,
)
class TestGetTheoreticalProvider:
"""Tests pour la fonction get_theoretical_provider."""
@pytest.fixture
def fixture_path(self) -> Path:
"""Retourne le chemin du fichier de fixture theoretical.json."""
return Path(__file__).parent.parent / "fixtures" / "theoretical.json"
@pytest.fixture
def holidays_path(self) -> Path:
"""Retourne le chemin du fichier de fixture school_holidays.json."""
return Path(__file__).parent.parent / "fixtures" / "school_holidays.json"
@pytest.fixture
def all_only_path(self, tmp_path: Path) -> Path:
"""Crée un fichier JSON avec uniquement des cours "all"."""
data = {
"version": 1,
"lessons": [
{
"week": "all",
"day_of_week": 0,
"start_time": "08:00",
"end_time": "09:00",
"subject": "Test",
}
],
}
file_path = tmp_path / "all_only.json"
file_path.write_text(json.dumps(data), encoding="utf-8")
return file_path
def test_factory_returns_none_when_path_none(self) -> None:
"""Teste que l'usine retourne None quand agenda_path est None.
:assert: get_theoretical_provider(None, None, None, None) retourne None.
"""
result = get_theoretical_provider(None, None, None, None)
assert result is None
def test_factory_returns_provider_with_full_config(
self, fixture_path: Path, holidays_path: Path
) -> None:
"""Teste que l'usine retourne un fournisseur avec une configuration complète.
:assert: Un fournisseur est retourné avec tous les paramètres.
"""
result = get_theoretical_provider(
agenda_path=str(fixture_path),
holidays_path=str(holidays_path),
anchor_date=date(2026, 9, 1),
anchor_type="even",
)
assert result is not None
assert isinstance(result, TheoreticalAgendaProvider)
def test_factory_partial_parity_config_error(self, fixture_path: Path) -> None:
"""Teste qu'une configuration de parité partielle lève une PronoteSyncError.
:assert: PronoteSyncError est levée quand anchor_date est fourni sans anchor_type.
"""
with pytest.raises(PronoteSyncError) as exc_info:
get_theoretical_provider(
agenda_path=str(fixture_path),
holidays_path=None,
anchor_date=date(2026, 9, 1),
anchor_type=None,
)
assert "incomplète" in str(exc_info.value)
def test_factory_partial_parity_config_error_type_only(self, fixture_path: Path) -> None:
"""Teste qu'une configuration de parité partielle (type seulement) lève une PronoteSyncError.
:assert: PronoteSyncError est levée quand anchor_type est fourni sans anchor_date.
"""
with pytest.raises(PronoteSyncError) as exc_info:
get_theoretical_provider(
agenda_path=str(fixture_path),
holidays_path=None,
anchor_date=None,
anchor_type="even",
)
assert "incomplète" in str(exc_info.value)
def test_factory_no_holidays(self, fixture_path: Path) -> None:
"""Teste que l'usine retourne un fournisseur sans calendrier de vacances.
:assert: Un fournisseur est retourné sans calendrier de vacances.
"""
result = get_theoretical_provider(
agenda_path=str(fixture_path),
holidays_path=None,
anchor_date=date(2026, 9, 1),
anchor_type="even",
)
assert result is not None
assert isinstance(result, TheoreticalAgendaProvider)
def test_factory_no_parity(self, all_only_path: Path) -> None:
"""Teste que l'usine retourne un fournisseur sans service de parité pour des cours "all".
:assert: Un fournisseur est retourné sans service de parité.
"""
result = get_theoretical_provider(
agenda_path=str(all_only_path),
holidays_path=None,
anchor_date=None,
anchor_type=None,
)
assert result is not None
assert isinstance(result, TheoreticalAgendaProvider)
def test_factory_protocol_compliance(self, fixture_path: Path, holidays_path: Path) -> None:
"""Teste que le fournisseur retourné respecte le protocole TheoreticalAgendaProvider.
:assert: Le fournisseur satisfait isinstance(provider, TheoreticalAgendaProvider).
"""
result = get_theoretical_provider(
agenda_path=str(fixture_path),
holidays_path=str(holidays_path),
anchor_date=date(2026, 9, 1),
anchor_type="even",
)
assert result is not None
assert isinstance(result, TheoreticalAgendaProvider)

View File

@@ -0,0 +1,188 @@
"""Tests unitaires pour le calendrier des vacances scolaires.
Ce module contient les tests pour les classes :class:`SchoolHolidayCalendar`,
:class:`HolidayPeriod` et :class:`SchoolHolidayFile` du module
:mod:`pronote_sync.sources.theoretical.holidays`.
"""
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
import pytest
from pronote_sync.errors import PronoteSyncError
from pronote_sync.sources.theoretical.holidays import SchoolHolidayCalendar
class TestSchoolHolidayCalendar:
"""Tests pour la classe SchoolHolidayCalendar."""
def test_load_valid_file(self, tmp_path: Path) -> None:
"""Teste le chargement d'un fichier JSON valide.
:assert: is_holiday retourne True pour une date dans une période.
"""
holiday_data = {
"zone": "A",
"school_year": "2026-2027",
"periods": [
{
"start_date": "2026-10-17",
"end_date": "2026-11-02",
"label": "Toussaint",
}
],
}
file_path = tmp_path / "holidays.json"
file_path.write_text(json.dumps(holiday_data), encoding="utf-8")
calendar = SchoolHolidayCalendar(file_path)
# Date dans la période de Toussaint
assert calendar.is_holiday(date(2026, 10, 20)) is True
def test_date_outside_periods(self, tmp_path: Path) -> None:
"""Teste qu'une date en dehors des périodes retourne False.
:assert: is_holiday retourne False pour une date hors période.
"""
holiday_data = {
"zone": "A",
"school_year": "2026-2027",
"periods": [
{
"start_date": "2026-10-17",
"end_date": "2026-11-02",
"label": "Toussaint",
}
],
}
file_path = tmp_path / "holidays.json"
file_path.write_text(json.dumps(holiday_data), encoding="utf-8")
calendar = SchoolHolidayCalendar(file_path)
# Date en dehors de la période
assert calendar.is_holiday(date(2026, 9, 1)) is False
def test_start_date_inclusive(self, tmp_path: Path) -> None:
"""Teste que la date de début est incluse dans la période.
:assert: is_holiday retourne True pour une date égale à start_date.
"""
holiday_data = {
"zone": "A",
"school_year": "2026-2027",
"periods": [
{
"start_date": "2026-10-17",
"end_date": "2026-11-02",
"label": "Toussaint",
}
],
}
file_path = tmp_path / "holidays.json"
file_path.write_text(json.dumps(holiday_data), encoding="utf-8")
calendar = SchoolHolidayCalendar(file_path)
assert calendar.is_holiday(date(2026, 10, 17)) is True
def test_end_date_inclusive(self, tmp_path: Path) -> None:
"""Teste que la date de fin est incluse dans la période.
:assert: is_holiday retourne True pour une date égale à end_date.
"""
holiday_data = {
"zone": "A",
"school_year": "2026-2027",
"periods": [
{
"start_date": "2026-10-17",
"end_date": "2026-11-02",
"label": "Toussaint",
}
],
}
file_path = tmp_path / "holidays.json"
file_path.write_text(json.dumps(holiday_data), encoding="utf-8")
calendar = SchoolHolidayCalendar(file_path)
assert calendar.is_holiday(date(2026, 11, 2)) is True
def test_file_not_found(self, tmp_path: Path) -> None:
"""Teste qu'un fichier introuvable lève une PronoteSyncError.
:assert: PronoteSyncError est levée pour un fichier introuvable.
"""
file_path = tmp_path / "nonexistent.json"
with pytest.raises(PronoteSyncError) as exc_info:
SchoolHolidayCalendar(file_path)
assert "introuvable" in str(exc_info.value)
# Vérifier qu'aucun secret n'est fuité dans le message d'erreur
assert "nonexistent" not in str(exc_info.value) or "introuvable" in str(exc_info.value)
def test_invalid_json(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""Teste qu'un fichier JSON invalide lève une PronoteSyncError.
:assert: PronoteSyncError est levée pour un JSON invalide.
"""
file_path = tmp_path / "invalid.json"
file_path.write_text("{ invalid json }", encoding="utf-8")
with pytest.raises(PronoteSyncError) as exc_info:
SchoolHolidayCalendar(file_path)
assert "invalide" in str(exc_info.value)
def test_invalid_period_dates(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""Teste qu'une période avec end_date < start_date lève une ValidationError.
:assert: PronoteSyncError est levée pour des dates de période invalides.
"""
holiday_data = {
"zone": "A",
"school_year": "2026-2027",
"periods": [
{
"start_date": "2026-11-02",
"end_date": "2026-10-17", # Inversé
"label": "Toussaint",
}
],
}
file_path = tmp_path / "holidays.json"
file_path.write_text(json.dumps(holiday_data), encoding="utf-8")
with pytest.raises(PronoteSyncError):
SchoolHolidayCalendar(file_path)
def test_empty_periods(self, tmp_path: Path) -> None:
"""Teste qu'un fichier avec des périodes vides retourne toujours False.
:assert: is_holiday retourne False pour toutes les dates.
"""
holiday_data = {
"zone": "A",
"school_year": "2026-2027",
"periods": [],
}
file_path = tmp_path / "holidays.json"
file_path.write_text(json.dumps(holiday_data), encoding="utf-8")
calendar = SchoolHolidayCalendar(file_path)
assert calendar.is_holiday(date(2026, 10, 20)) is False
assert calendar.is_holiday(date(2026, 1, 1)) is False
def test_load_from_fixture(self) -> None:
"""Teste le chargement du fichier de fixture et vérifie une date connue.
:assert: is_holiday retourne True pour une date de vacances connue.
"""
fixture_path = Path(__file__).parent.parent / "fixtures" / "school_holidays.json"
calendar = SchoolHolidayCalendar(fixture_path)
# Date dans les vacances de Toussaint (17 oct - 2 nov 2026)
assert calendar.is_holiday(date(2026, 10, 20)) is True
# Date dans les vacances de Noël (19 déc 2026 - 4 janv 2027)
assert calendar.is_holiday(date(2026, 12, 25)) is True
# Date en dehors des vacances
assert calendar.is_holiday(date(2026, 9, 1)) is False

View File

@@ -0,0 +1,201 @@
"""Tests unitaires pour les modèles de parsing de l'agenda théorique.
Ce module contient les tests pour les classes :class:`TheoreticalLessonEntry`
et :class:`TheoreticalAgendaFile` du module
:mod:`pronote_sync.sources.theoretical.model`.
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from pronote_sync.sources.theoretical.model import (
TheoreticalAgendaFile,
TheoreticalLessonEntry,
)
class TestTheoreticalLessonEntry:
"""Tests pour la classe TheoreticalLessonEntry."""
def test_valid_lesson_entry(self) -> None:
"""Teste la construction d'une entrée de cours valide.
:assert: Tous les champs sont correctement initialisés.
"""
entry = TheoreticalLessonEntry(
week="all",
day_of_week=0,
start_time="08:00",
end_time="09:00",
subject="Mathématiques",
teachers=("Mme Martin",),
rooms=("101",),
id="math-1",
)
assert entry.week == "all"
assert entry.day_of_week == 0
assert entry.start_time == "08:00"
assert entry.end_time == "09:00"
assert entry.subject == "Mathématiques"
assert entry.teachers == ("Mme Martin",)
assert entry.rooms == ("101",)
assert entry.id == "math-1"
def test_invalid_time_format(self) -> None:
"""Teste qu'une heure invalide (25:00) lève une ValidationError.
:assert: ValidationError est levée pour start_time="25:00".
"""
with pytest.raises(ValidationError) as exc_info:
TheoreticalLessonEntry(
week="all",
day_of_week=0,
start_time="25:00",
end_time="09:00",
subject="Test",
)
assert "start_time" in str(exc_info.value)
def test_invalid_time_format_minutes(self) -> None:
"""Teste qu'une minute invalide (60) lève une ValidationError.
:assert: ValidationError est levée pour start_time="08:60".
"""
with pytest.raises(ValidationError) as exc_info:
TheoreticalLessonEntry(
week="all",
day_of_week=0,
start_time="08:60",
end_time="09:00",
subject="Test",
)
assert "start_time" in str(exc_info.value)
def test_end_before_start(self) -> None:
"""Teste qu'une fin avant le début lève une ValidationError.
:assert: ValidationError est levée pour end_time < start_time.
"""
with pytest.raises(ValidationError) as exc_info:
TheoreticalLessonEntry(
week="all",
day_of_week=0,
start_time="10:00",
end_time="09:00",
subject="Test",
)
assert "end_time" in str(exc_info.value)
def test_end_equal_start(self) -> None:
"""Teste qu'une fin égale au début lève une ValidationError.
:assert: ValidationError est levée pour end_time == start_time.
"""
with pytest.raises(ValidationError) as exc_info:
TheoreticalLessonEntry(
week="all",
day_of_week=0,
start_time="09:00",
end_time="09:00",
subject="Test",
)
assert "end_time" in str(exc_info.value)
def test_invalid_week(self) -> None:
"""Teste qu'une semaine invalide lève une ValidationError.
:assert: ValidationError est levée pour week="weekly".
"""
with pytest.raises(ValidationError) as exc_info:
TheoreticalLessonEntry(
week="weekly", # type: ignore[arg-type]
day_of_week=0,
start_time="08:00",
end_time="09:00",
subject="Test",
)
assert "week" in str(exc_info.value)
def test_invalid_day_of_week(self) -> None:
"""Teste qu'un jour de semaine invalide (7) lève une ValidationError.
:assert: ValidationError est levée pour day_of_week=7.
"""
with pytest.raises(ValidationError) as exc_info:
TheoreticalLessonEntry(
week="all",
day_of_week=7,
start_time="08:00",
end_time="09:00",
subject="Test",
)
assert "day_of_week" in str(exc_info.value)
def test_optional_fields_defaults(self) -> None:
"""Teste que les champs optionnels ont des valeurs par défaut.
:assert: teachers et rooms valent () par défaut.
"""
entry = TheoreticalLessonEntry(
week="all",
day_of_week=0,
start_time="08:00",
end_time="09:00",
subject="Test",
)
assert entry.teachers == ()
assert entry.rooms == ()
def test_frozen_model(self) -> None:
"""Teste que le modèle est immuable (frozen).
:assert: La tentative de mutation lève une exception.
"""
entry = TheoreticalLessonEntry(
week="all",
day_of_week=0,
start_time="08:00",
end_time="09:00",
subject="Test",
)
with pytest.raises((ValidationError, AttributeError)):
entry.subject = "Nouveau"
class TestTheoreticalAgendaFile:
"""Tests pour la classe TheoreticalAgendaFile."""
def test_agenda_file_valid(self) -> None:
"""Teste la construction d'un fichier d'agenda valide.
:assert: version == 1 et lessons a le bon nombre d'entrées.
"""
lessons = [
TheoreticalLessonEntry(
week="all",
day_of_week=0,
start_time="08:00",
end_time="09:00",
subject="Mathématiques",
),
TheoreticalLessonEntry(
week="even",
day_of_week=1,
start_time="10:00",
end_time="11:00",
subject="Anglais",
),
]
agenda = TheoreticalAgendaFile(version=1, lessons=tuple(lessons))
assert agenda.version == 1
assert len(agenda.lessons) == 2
def test_agenda_file_default_version(self) -> None:
"""Teste que la version par défaut est 1.
:assert: version vaut 1 par défaut.
"""
agenda = TheoreticalAgendaFile(lessons=())
assert agenda.version == 1

View File

@@ -0,0 +1,116 @@
"""Tests unitaires pour le service de parité des semaines.
Ce module contient les tests pour la classe :class:`WeekParityService`
du module :mod:`pronote_sync.sources.theoretical.parity`.
"""
from __future__ import annotations
from datetime import date
from pronote_sync.sources.theoretical.parity import WeekParityService
class TestWeekParityService:
"""Tests pour la classe WeekParityService."""
def test_same_week_as_anchor_even(self) -> None:
"""Teste qu'une date dans la même semaine que l'ancrage pair retourne "even".
:assert: parity_for retourne "even" pour une date dans la même semaine.
"""
anchor_date = date(2026, 9, 1) # Mardi
service = WeekParityService(anchor_date, "even")
# Même semaine (lundi 31 août 2026)
target_date = date(2026, 9, 1)
assert service.parity_for(target_date) == "even"
def test_one_week_after_anchor_even(self) -> None:
"""Teste qu'une date une semaine après l'ancrage pair retourne "odd".
:assert: parity_for retourne "odd" pour une date une semaine après.
"""
anchor_date = date(2026, 9, 1) # Mardi
service = WeekParityService(anchor_date, "even")
# Une semaine après (mardi 8 septembre 2026)
target_date = date(2026, 9, 8)
assert service.parity_for(target_date) == "odd"
def test_two_weeks_after_anchor_even(self) -> None:
"""Teste qu'une date deux semaines après l'ancrage pair retourne "even".
:assert: parity_for retourne "even" pour une date deux semaines après.
"""
anchor_date = date(2026, 9, 1) # Mardi
service = WeekParityService(anchor_date, "even")
# Deux semaines après (mardi 15 septembre 2026)
target_date = date(2026, 9, 15)
assert service.parity_for(target_date) == "even"
def test_same_week_as_anchor_odd(self) -> None:
"""Teste qu'une date dans la même semaine que l'ancrage impair retourne "odd".
:assert: parity_for retourne "odd" pour une date dans la même semaine.
"""
anchor_date = date(2026, 9, 1) # Mardi
service = WeekParityService(anchor_date, "odd")
target_date = date(2026, 9, 1)
assert service.parity_for(target_date) == "odd"
def test_one_week_before_anchor(self) -> None:
"""Teste qu'une date une semaine avant l'ancrage retourne la parité opposée.
:assert: parity_for retourne la parité opposée pour une date une semaine avant.
"""
anchor_date = date(2026, 9, 8) # Mardi
service = WeekParityService(anchor_date, "even")
# Une semaine avant (mardi 1er septembre 2026)
target_date = date(2026, 9, 1)
assert service.parity_for(target_date) == "odd"
def test_many_weeks_later(self) -> None:
"""Teste qu'une date 10 semaines après l'ancrage pair retourne "even".
:assert: parity_for retourne "even" pour une date 10 semaines après.
"""
anchor_date = date(2026, 9, 1) # Mardi
service = WeekParityService(anchor_date, "even")
# 10 semaines après (70 jours)
target_date = date(2026, 11, 10)
assert service.parity_for(target_date) == "even"
def test_negative_offset(self) -> None:
"""Teste qu'une date loin avant l'ancrage calcule correctement la parité.
:assert: parity_for retourne la parité correcte pour une date loin dans le passé.
"""
anchor_date = date(2026, 9, 1) # Mardi
service = WeekParityService(anchor_date, "even")
# 10 semaines avant (70 jours)
target_date = date(2026, 6, 23)
assert service.parity_for(target_date) == "even"
def test_different_day_in_same_week(self) -> None:
"""Teste que lundi et vendredi de la même semaine ont la même parité.
:assert: parity_for retourne la même parité pour lundi et vendredi de la même semaine.
"""
anchor_date = date(2026, 9, 1) # Mardi
service = WeekParityService(anchor_date, "even")
# Lundi de la même semaine
monday = date(2026, 8, 31)
# Vendredi de la même semaine
friday = date(2026, 9, 4)
assert service.parity_for(monday) == "even"
assert service.parity_for(friday) == "even"
def test_crosses_year_boundary(self) -> None:
"""Teste que le calcul de parité fonctionne à cheval sur une année.
:assert: parity_for retourne la parité correcte à cheval sur une année.
"""
anchor_date = date(2026, 12, 29) # Mardi (semaine 53)
service = WeekParityService(anchor_date, "even")
# Date dans la semaine suivante (année 2027)
target_date = date(2027, 1, 5) # Mardi de la semaine suivante
assert service.parity_for(target_date) == "odd"

View File

@@ -0,0 +1,722 @@
"""Tests unitaires pour le fournisseur d'agenda théorique basé sur fichier JSON.
Ce module contient les tests pour la classe :class:`JsonTheoreticalAgendaProvider`
du module :mod:`pronote_sync.sources.theoretical.file`.
"""
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
import pytest
from pronote_sync.errors import PronoteSyncError
from pronote_sync.sources.theoretical.file import JsonTheoreticalAgendaProvider, normalize_subject
from pronote_sync.sources.theoretical.holidays import SchoolHolidayCalendar
from pronote_sync.sources.theoretical.parity import WeekParityService
class TestJsonTheoreticalAgendaProvider:
"""Tests pour la classe JsonTheoreticalAgendaProvider."""
@pytest.fixture
def fixture_path(self) -> Path:
"""Retourne le chemin du fichier de fixture theoretical.json."""
return Path(__file__).parent.parent / "fixtures" / "theoretical.json"
@pytest.fixture
def holidays_path(self) -> Path:
"""Retourne le chemin du fichier de fixture school_holidays.json."""
return Path(__file__).parent.parent / "fixtures" / "school_holidays.json"
@pytest.fixture
def all_only_path(self, tmp_path: Path) -> Path:
"""Crée un fichier JSON avec uniquement des cours "all"."""
data = {
"version": 1,
"lessons": [
{
"id": "theoretical-maths-monday-1",
"week": "all",
"day_of_week": 0,
"start_time": "08:00",
"end_time": "09:00",
"subject": "Mathématiques",
"teachers": ["Mme Martin"],
"rooms": ["101"],
},
{
"week": "all",
"day_of_week": 0,
"start_time": "09:00",
"end_time": "10:00",
"subject": "Français",
"teachers": ["M. Dupont"],
"rooms": ["102"],
},
{
"week": "all",
"day_of_week": 0,
"start_time": "11:00",
"end_time": "12:00",
"subject": "Histoire-Géographie",
"teachers": ["Mme Petit"],
"rooms": ["103"],
},
{
"week": "all",
"day_of_week": 2,
"start_time": "14:00",
"end_time": "15:00",
"subject": "Sciences",
"teachers": [],
"rooms": ["203"],
},
{
"week": "all",
"day_of_week": 4,
"start_time": "09:00",
"end_time": "10:00",
"subject": "Arts plastiques",
"teachers": [],
"rooms": [],
},
],
}
file_path = tmp_path / "all_only.json"
file_path.write_text(json.dumps(data), encoding="utf-8")
return file_path
@pytest.fixture
def provider_no_parity_no_holidays(self, all_only_path: Path) -> JsonTheoreticalAgendaProvider:
"""Fournisseur sans service de parité ni calendrier de vacances."""
return JsonTheoreticalAgendaProvider(
file_path=str(all_only_path),
parity_service=None,
holiday_calendar=None,
)
@pytest.fixture
def provider_with_parity(self, fixture_path: Path) -> JsonTheoreticalAgendaProvider:
"""Fournisseur avec service de parité (ancrage sur 2026-09-01, even)."""
parity_service = WeekParityService(date(2026, 9, 1), "even")
return JsonTheoreticalAgendaProvider(
file_path=str(fixture_path),
parity_service=parity_service,
holiday_calendar=None,
)
@pytest.fixture
def provider_with_holidays(
self, fixture_path: Path, holidays_path: Path
) -> JsonTheoreticalAgendaProvider:
"""Fournisseur avec calendrier de vacances."""
holiday_calendar = SchoolHolidayCalendar(holidays_path)
return JsonTheoreticalAgendaProvider(
file_path=str(fixture_path),
parity_service=None,
holiday_calendar=holiday_calendar,
)
@pytest.fixture
def provider_full(
self, fixture_path: Path, holidays_path: Path
) -> JsonTheoreticalAgendaProvider:
"""Fournisseur avec service de parité et calendrier de vacances."""
parity_service = WeekParityService(date(2026, 9, 1), "even")
holiday_calendar = SchoolHolidayCalendar(holidays_path)
return JsonTheoreticalAgendaProvider(
file_path=str(fixture_path),
parity_service=parity_service,
holiday_calendar=holiday_calendar,
)
def test_get_lessons_all_weeks(
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
) -> None:
"""Teste la récupération des cours pour un lundi (jour 0) sans parité.
:assert: Les cours "all" pour le lundi sont retournés.
"""
# Lundi 2026-08-31 (weekday = 0)
target_date = date(2026, 8, 31)
lessons = provider_no_parity_no_holidays.get_lessons(target_date)
# Le fichier all_only a 3 cours "all" pour le lundi (day_of_week=0)
assert len(lessons) == 3
subjects = [lesson.subject for lesson in lessons]
assert "Mathématiques" in subjects
assert "Français" in subjects
assert "Histoire-Géographie" in subjects
def test_get_lessons_even_week(
self, provider_with_parity: JsonTheoreticalAgendaProvider
) -> None:
"""Teste la récupération des cours pour une semaine paire.
:assert: Les cours "even" pour le mardi sont retournés.
"""
# Mardi 2026-09-01 (weekday = 1), semaine paire (ancrage 2026-09-01 even)
# Note: 2026-09-01 est mardi (weekday=1)
target_date = date(2026, 9, 1)
lessons = provider_with_parity.get_lessons(target_date)
# Le fixture a 2 cours "even" pour le mardi (day_of_week=1)
subjects = [lesson.subject for lesson in lessons]
assert "Anglais" in subjects
assert "Technologie" in subjects
def test_get_lessons_odd_week(
self, provider_with_parity: JsonTheoreticalAgendaProvider
) -> None:
"""Teste la récupération des cours pour une semaine impaire.
:assert: Les cours "odd" pour le mardi sont retournés.
"""
# Mardi 2026-09-08 (weekday = 1), semaine impaire (1 semaine après l'ancrage 2026-09-01)
target_date = date(2026, 9, 8)
lessons = provider_with_parity.get_lessons(target_date)
# Le fixture a 2 cours "odd" pour le mardi (day_of_week=1)
subjects = [lesson.subject for lesson in lessons]
assert "Espagnol" in subjects
assert "Éducation musicale" in subjects
def test_get_lessons_holiday_returns_empty(
self, provider_full: JsonTheoreticalAgendaProvider
) -> None:
"""Teste qu'une date en vacances retourne une liste vide.
:assert: get_lessons retourne [] pour une date en vacances.
"""
# Date pendant les vacances de Toussaint (17 oct - 2 nov 2026)
target_date = date(2026, 10, 20)
lessons = provider_full.get_lessons(target_date)
assert lessons == []
def test_get_lessons_no_holiday_calendar(
self, provider_with_parity: JsonTheoreticalAgendaProvider
) -> None:
"""Teste que sans calendrier de vacances, les cours sont retournés même en vacances.
:assert: Les cours sont retournés pour une date en vacances.
"""
# Date pendant les vacances de Toussaint, mais sans calendrier de vacances
target_date = date(2026, 10, 20) # Mardi (weekday = 1)
lessons = provider_with_parity.get_lessons(target_date)
# Sans calendrier de vacances, les cours "even" pour le mardi sont retournés
assert len(lessons) == 2
def test_get_lessons_sorted_by_id(
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
) -> None:
"""Teste que les cours sont triés par identifiant.
:assert: Les cours sont triés par id.
"""
# Lundi 2026-08-31 (weekday = 0)
target_date = date(2026, 8, 31)
lessons = provider_no_parity_no_holidays.get_lessons(target_date)
ids = [lesson.id for lesson in lessons]
assert ids == sorted(ids)
def test_get_lessons_deterministic(
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
) -> None:
"""Teste que get_lessons retourne des résultats identiques pour la même date.
:assert: Deux appels avec la même date retournent des résultats identiques.
"""
# Lundi 2026-08-31 (weekday = 0)
target_date = date(2026, 8, 31)
lessons1 = provider_no_parity_no_holidays.get_lessons(target_date)
lessons2 = provider_no_parity_no_holidays.get_lessons(target_date)
assert lessons1 == lessons2
def test_even_odd_different_subjects_different_ids(
self, provider_with_parity: JsonTheoreticalAgendaProvider
) -> None:
"""Teste que les cours even/odd sur le même créneau avec des matières différentes ont des IDs différents.
:assert: Les IDs des cours even et odd sont différents.
"""
# Mardi 2026-09-01 (semaine paire, même semaine que l'ancrage)
even_lessons = provider_with_parity.get_lessons(date(2026, 9, 1))
# Mardi 2026-09-08 (semaine impaire, 1 semaine après l'ancrage)
odd_lessons = provider_with_parity.get_lessons(date(2026, 9, 8))
# Les deux semaines ont des cours sur le même créneau (10:00-11:00)
# mais avec des matières différentes (Anglais/Technologie vs Espagnol/Éducation musicale)
even_subjects = {lesson.subject for lesson in even_lessons}
odd_subjects = {lesson.subject for lesson in odd_lessons}
# Les matières doivent être différentes
assert "Anglais" in even_subjects
assert "Espagnol" in odd_subjects
# Les IDs ne doivent pas être identiques
even_ids = {lesson.id for lesson in even_lessons}
odd_ids = {lesson.id for lesson in odd_lessons}
assert even_ids.isdisjoint(odd_ids)
def test_even_odd_same_subject_different_ids(self, tmp_path: Path) -> None:
"""Vérifie que les leçons paire/impaire sur le même créneau avec la même matière ont des IDs distincts.
Utilise la même matière pour isoler la parité comme seul différenciateur.
:assert: Les IDs des cours even et odd sont différents, avec la même matière.
"""
json_content = json.dumps(
{
"version": 1,
"lessons": [
{
"week": "even",
"day_of_week": 1,
"start_time": "10:00",
"end_time": "11:00",
"subject": "Langue vivante",
"teachers": [],
"rooms": [],
},
{
"week": "odd",
"day_of_week": 1,
"start_time": "10:00",
"end_time": "11:00",
"subject": "Langue vivante",
"teachers": [],
"rooms": [],
},
],
}
)
file_path = tmp_path / "theoretical.json"
file_path.write_text(json_content, encoding="utf-8")
anchor_date = date(2026, 9, 1) # Tuesday
parity = WeekParityService(anchor_date, "even")
provider = JsonTheoreticalAgendaProvider(file_path=str(file_path), parity_service=parity)
# Tuesday in even week
even_tuesday = date(2026, 9, 1) # Same week as anchor (even)
odd_tuesday = date(2026, 9, 8) # One week later (odd)
even_lessons = provider.get_lessons(even_tuesday)
odd_lessons = provider.get_lessons(odd_tuesday)
assert len(even_lessons) == 1
assert len(odd_lessons) == 1
assert even_lessons[0].id != odd_lessons[0].id
# The only difference in the ID should be the week type
assert "even" in even_lessons[0].id
assert "odd" in odd_lessons[0].id
def test_explicit_id_preserved(
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
) -> None:
"""Teste qu'un ID explicite est préservé.
:assert: L'ID explicite "theoretical-maths-monday-1" est utilisé.
"""
# Lundi 2026-08-31 (weekday = 0)
target_date = date(2026, 8, 31)
lessons = provider_no_parity_no_holidays.get_lessons(target_date)
ids = [lesson.id for lesson in lessons]
assert "theoretical-maths-monday-1" in ids
def test_generated_id_includes_week(
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
) -> None:
"""Teste qu'un ID généré inclut le type de semaine.
:assert: L'ID généré contient le type de semaine.
"""
# Lundi 2026-08-31 (weekday = 0)
target_date = date(2026, 8, 31)
lessons = provider_no_parity_no_holidays.get_lessons(target_date)
# Trouver un cours sans ID explicite (Français ou Histoire-Géographie)
for lesson in lessons:
if lesson.subject in ("Français", "Histoire-Géographie"):
assert "all" in lesson.id
break
def test_no_parity_service_with_all_only_lessons(self, all_only_path: Path) -> None:
"""Teste qu'un fournisseur sans service de parité fonctionne avec des cours "all".
:assert: Les cours "all" sont retournés correctement.
"""
provider = JsonTheoreticalAgendaProvider(
file_path=str(all_only_path),
parity_service=None,
holiday_calendar=None,
)
# Lundi 2026-08-31 (weekday = 0)
target_date = date(2026, 8, 31)
lessons = provider.get_lessons(target_date)
assert len(lessons) == 3
def test_even_lessons_without_parity_service_error(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Teste qu'un fichier avec des cours even/odd sans service de parité lève une erreur.
:assert: PronoteSyncError est levée.
"""
# Créer un fichier JSON avec uniquement des cours even/odd
data = {
"version": 1,
"lessons": [
{
"week": "even",
"day_of_week": 0,
"start_time": "08:00",
"end_time": "09:00",
"subject": "Test",
}
],
}
file_path = tmp_path / "even_only.json"
file_path.write_text(json.dumps(data), encoding="utf-8")
with pytest.raises(PronoteSyncError) as exc_info:
JsonTheoreticalAgendaProvider(
file_path=str(file_path),
parity_service=None,
holiday_calendar=None,
)
assert "ancre de parité" in str(exc_info.value)
def test_invalid_json_file(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""Teste qu'un fichier JSON invalide lève une PronoteSyncError.
:assert: PronoteSyncError est levée pour un JSON invalide.
"""
file_path = tmp_path / "invalid.json"
file_path.write_text("{ invalid json }", encoding="utf-8")
with pytest.raises(PronoteSyncError) as exc_info:
JsonTheoreticalAgendaProvider(
file_path=str(file_path),
parity_service=None,
holiday_calendar=None,
)
assert "invalide" in str(exc_info.value)
def test_get_lessons_for_range(
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
) -> None:
"""Teste la récupération des cours pour une plage de 5 jours.
:assert: Tous les cours uniques sont retournés, triés par ID.
"""
# Lundi 2026-08-31 à Vendredi 2026-09-04
start_date = date(2026, 8, 31) # Lundi
end_date = date(2026, 9, 4) # Vendredi
lessons = provider_no_parity_no_holidays.get_lessons_for_range(start_date, end_date)
# Le fichier all_only a des cours pour lundi (3), mercredi (1), vendredi (1)
# Total: 3 + 1 + 1 = 5 cours uniques
assert len(lessons) == 5
ids = [lesson.id for lesson in lessons]
assert ids == sorted(ids)
def test_get_lessons_for_range_with_holidays(
self, provider_full: JsonTheoreticalAgendaProvider
) -> None:
"""Teste la récupération des cours pour une plage incluant des jours de vacances.
:assert: Les jours de vacances ne contribuent pas de cours.
"""
# Plage incluant des vacances de Toussaint (17 oct - 2 nov 2026)
# Le 15 oct 2026 est un mercredi (day_of_week=2), le fixture a un cours "all" pour mercredi
# Le 16 oct 2026 est un jeudi (day_of_week=3), pas de cours dans le fixture
# Le 17 oct 2026 est un vendredi (day_of_week=4), le fixture a un cours "all" pour vendredi
# Mais le 17 oct est le début des vacances, donc pas de cours
start_date = date(2026, 10, 15) # Mercredi (avant vacances)
end_date = date(2026, 10, 16) # Jeudi (avant vacances)
lessons = provider_full.get_lessons_for_range(start_date, end_date)
# Le 15 oct (mercredi) devrait avoir un cours "all" pour Sciences
# Le 16 oct (jeudi) n'a pas de cours dans le fixture
# Donc on devrait avoir au moins 1 cours
assert len(lessons) >= 1
def test_get_lessons_for_range_empty(
self, provider_full: JsonTheoreticalAgendaProvider
) -> None:
"""Teste qu'une plage où tous les jours sont en vacances retourne une liste vide.
:assert: get_lessons_for_range retourne [] pour une plage entièrement en vacances.
"""
# Plage entièrement pendant les vacances de Toussaint
start_date = date(2026, 10, 17)
end_date = date(2026, 10, 24)
lessons = provider_full.get_lessons_for_range(start_date, end_date)
assert lessons == []
def test_get_lessons_sunday(
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
) -> None:
"""Teste qu'un dimanche retourne une liste vide.
:assert: get_lessons retourne [] pour un dimanche.
"""
# Dimanche 2026-09-06 (weekday = 6)
target_date = date(2026, 9, 6)
lessons = provider_no_parity_no_holidays.get_lessons(target_date)
# Aucun cours n'est prévu pour le dimanche dans le fichier all_only
assert lessons == []
def test_get_lessons_for_range_dedup_across_weeks(self, tmp_path: Path) -> None:
"""Vérifie que get_lessons_for_range déduplique les leçons récurrentes.
:assert: Une leçon récurrente sur plusieurs semaines n'apparaît qu'une fois.
"""
# Créer un fichier JSON avec une seule leçon "all" le lundi 08:00-09:00 "Maths"
data = {
"version": 1,
"lessons": [
{
"week": "all",
"day_of_week": 0, # Lundi
"start_time": "08:00",
"end_time": "09:00",
"subject": "Maths",
"teachers": [],
"rooms": [],
}
],
}
file_path = tmp_path / "dedup_test.json"
file_path.write_text(json.dumps(data), encoding="utf-8")
provider = JsonTheoreticalAgendaProvider(
file_path=str(file_path),
parity_service=None,
holiday_calendar=None,
)
# Plage du 31 août 2026 (lundi) au 11 septembre 2026 (vendredi)
# Cela couvre 2 lundis (31 août et 7 septembre)
start_date = date(2026, 8, 31)
end_date = date(2026, 9, 11)
lessons = provider.get_lessons_for_range(start_date, end_date)
# La leçon récurrente devrait apparaître une seule fois
assert len(lessons) == 1
assert lessons[0].subject == "Maths"
def test_duplicate_explicit_ids_rejected(self, tmp_path: Path) -> None:
"""Vérifie que les IDs explicites dupliqués sont rejetés.
:assert: PronoteSyncError est levée pour des IDs explicites dupliqués.
"""
# Créer un fichier JSON avec 2 leçons ayant le même ID explicite
data = {
"version": 1,
"lessons": [
{
"id": "dup-id",
"week": "all",
"day_of_week": 0,
"start_time": "08:00",
"end_time": "09:00",
"subject": "Maths",
"teachers": [],
"rooms": [],
},
{
"id": "dup-id",
"week": "all",
"day_of_week": 1,
"start_time": "09:00",
"end_time": "10:00",
"subject": "Français",
"teachers": [],
"rooms": [],
},
],
}
file_path = tmp_path / "duplicate_ids.json"
file_path.write_text(json.dumps(data), encoding="utf-8")
with pytest.raises(PronoteSyncError) as exc_info:
JsonTheoreticalAgendaProvider(
file_path=str(file_path),
parity_service=None,
holiday_calendar=None,
)
assert "Conflit d'identifiant" in str(exc_info.value) or "identifiant" in str(
exc_info.value
)
def test_generated_id_collision_rejected(self, tmp_path: Path) -> None:
"""Vérifie que les collisions d'IDs générés sont détectées.
:assert: PronoteSyncError est levée pour des IDs générés identiques.
"""
# Créer un fichier JSON avec 2 leçons qui produisent le même ID généré
# Même week, day_of_week, start_time, end_time, subject mais enseignants différents
data = {
"version": 1,
"lessons": [
{
"week": "all",
"day_of_week": 0,
"start_time": "08:00",
"end_time": "09:00",
"subject": "Maths",
"teachers": ["Prof1"],
"rooms": [],
},
{
"week": "all",
"day_of_week": 0,
"start_time": "08:00",
"end_time": "09:00",
"subject": "Maths",
"teachers": ["Prof2"],
"rooms": [],
},
],
}
file_path = tmp_path / "generated_id_collision.json"
file_path.write_text(json.dumps(data), encoding="utf-8")
with pytest.raises(PronoteSyncError) as exc_info:
JsonTheoreticalAgendaProvider(
file_path=str(file_path),
parity_service=None,
holiday_calendar=None,
)
assert "Conflit d'identifiant" in str(exc_info.value) or "identifiant" in str(
exc_info.value
)
def test_get_lessons_for_range_mixed_holidays_and_normal(self, tmp_path: Path) -> None:
"""Vérifie qu'une plage mixte (jours normaux et vacances) retourne les leçons des jours non vacanciers.
:assert: Les leçons des jours non vacanciers sont retournées, les jours de vacances sont ignorés.
"""
# Créer un fichier JSON avec une leçon "all" le jeudi 14:00-15:00 "Sciences"
# Note: 2026-10-15 est un jeudi (weekday=3)
data = {
"version": 1,
"lessons": [
{
"week": "all",
"day_of_week": 3, # Jeudi
"start_time": "14:00",
"end_time": "15:00",
"subject": "Sciences",
"teachers": [],
"rooms": [],
}
],
}
file_path = tmp_path / "mixed_holidays.json"
file_path.write_text(json.dumps(data), encoding="utf-8")
# Créer un fichier de vacances avec une période du 16 au 20 octobre 2026
holidays_data = {
"zone": "A",
"school_year": "2026-2027",
"periods": [
{"start_date": "2026-10-16", "end_date": "2026-10-20", "label": "Test Vacances"}
],
}
holidays_path = tmp_path / "test_holidays.json"
holidays_path.write_text(json.dumps(holidays_data), encoding="utf-8")
holiday_calendar = SchoolHolidayCalendar(holidays_path)
provider = JsonTheoreticalAgendaProvider(
file_path=str(file_path),
parity_service=None,
holiday_calendar=holiday_calendar,
)
# Plage du 15 octobre (jeudi, pas en vacances) au 17 octobre (samedi, en vacances)
start_date = date(2026, 10, 15) # Jeudi
end_date = date(2026, 10, 17) # Samedi
lessons = provider.get_lessons_for_range(start_date, end_date)
# Seule la leçon du 15 octobre (jeudi) devrait être retournée
assert len(lessons) == 1
assert lessons[0].subject == "Sciences"
def test_normalize_subject_variants_produce_same_id(self, tmp_path: Path) -> None:
"""Vérifie que des variantes de casse, d'espacement et d'Unicode produisent le même ID.
:assert: Les variantes de la même matière produisent le même ID normalisé.
"""
# Test the normalize_subject function directly
# Note: hyphens are removed entirely (not replaced with spaces) by normalize_subject
variants = [
"Mathématiques avancées",
"mathématiques avancées",
"Mathématiques avancées",
"MATHÉMATIQUES AVANCÉES",
]
normalized = [normalize_subject(variant) for variant in variants]
# All should normalize to the same value
assert all(n == normalized[0] for n in normalized)
# Should be lowercase, no extra spaces, no punctuation
assert normalized[0] == "mathématiques avancées"
def test_collision_error_no_secret_leak(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Vérifie qu'une sentinelle dans un ID dupliqué n'apparaît pas dans l'erreur.
:assert: PronoteSyncError est levée et la sentinelle n'apparaît pas dans l'erreur.
"""
# Use a secret pattern that the redaction system will actually catch
# The _ISOLATED_SECRET_PATTERN looks for things like secret=value or secret: value
sentinel_value = "SENTINELLE_M6_SECRET"
# Create an ID that contains a pattern like "secret=value" which will be redacted
secret_pattern = f"secret={sentinel_value}"
# Créer un fichier JSON avec 2 entrées ayant le même ID explicite contenant la sentinelle
data = {
"version": 1,
"lessons": [
{
"id": f"theoretical-{secret_pattern}-1",
"week": "all",
"day_of_week": 0,
"start_time": "08:00",
"end_time": "09:00",
"subject": "Maths",
"teachers": [],
"rooms": [],
},
{
"id": f"theoretical-{secret_pattern}-1",
"week": "all",
"day_of_week": 1,
"start_time": "09:00",
"end_time": "10:00",
"subject": "Français",
"teachers": [],
"rooms": [],
},
],
}
file_path = tmp_path / "collision_test.json"
file_path.write_text(json.dumps(data), encoding="utf-8")
with caplog.at_level("ERROR"):
with pytest.raises(PronoteSyncError) as exc_info:
JsonTheoreticalAgendaProvider(
file_path=str(file_path),
parity_service=None,
holiday_calendar=None,
)
# Check that the sentinel value does not appear in the error message
assert sentinel_value not in str(exc_info.value)
# Check that the sentinel value does not appear in the logs
assert sentinel_value not in caplog.text
# Check that the secret pattern was redacted (should contain REDACTED)
assert "REDACTED" in str(exc_info.value)
# Check that the sentinel does not appear in the cause
if exc_info.value.__cause__ is not None:
assert sentinel_value not in repr(exc_info.value.__cause__)