Compare commits

...

4 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
6 changed files with 341 additions and 200 deletions

View File

@@ -26,7 +26,7 @@ repos:
name: mypy name: mypy
entry: mypy entry: mypy
language: python 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] types: [python]
pass_filenames: true pass_filenames: true

View File

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

View File

@@ -2712,14 +2712,95 @@ class SynthesisResult(BaseModel):
### 7.1 Principes ### 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)). - **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. - **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`. - **Fenêtre de synchronisation** : Configurable via `SYNC_PAST_DAYS` et `SYNC_FUTURE_DAYS`.
- **Mode dry-run** : Obligatoire pour tester sans modifier le calendrier distant. - **Mode dry-run** : Obligatoire pour tester sans modifier le calendrier distant.
- **Idempotence** : Deux exécutions identiques **doivent** produire le même état CalDAV. - **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`) ### 7.2 Client CalDAV (`sync/caldav.py`)
Utilisation de la bibliothèque [`caldav`](https://pypi.org/project/caldav/) (Python 3.8+, maintenue). 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 ```python
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -2748,13 +2829,13 @@ class CalDAVClient:
url: str, url: str,
username: str, username: str,
password: str, password: str,
calendar_name: str = "Pronote", calendar_path: str = "/pronote-sync/",
dry_run: bool = False, dry_run: bool = False,
): ):
self.url = url self.url = url
self.username = username self.username = username
self.password = password self.password = password
self.calendar_name = calendar_name self.calendar_path = calendar_path
self.dry_run = dry_run self.dry_run = dry_run
self._client: Optional[caldav.DAVClient] = None self._client: Optional[caldav.DAVClient] = None
self._calendar: Optional[DAVCalendar] = None self._calendar: Optional[DAVCalendar] = None
@@ -2767,40 +2848,39 @@ class CalDAVClient:
password=self.password, password=self.password,
) )
# Récupérer ou créer le calendrier # Résoudre le calendrier via calendar_path (cf. CalDAVSettings) :
try: # API réelle (caldav>=1.3.0) : principal.calendars() puis correspondance
self._calendar = self._client.calendar(name=self.calendar_name) # sur l'URL du calendrier.
except caldav.lib.error.NotFoundError: principal = self._client.principal()
# Créer le calendrier s'il n'existe pas matches = [
if not self.dry_run: c for c in principal.calendars()
self._calendar = self._client.make_calendar( if str(c.url).rstrip("/").endswith(self.calendar_path.rstrip("/"))
name=self.calendar_name, ]
supported_calendar_components=["VEVENT"], if matches:
) self._calendar = matches[0]
else: else:
logger.warning( # Pas de création automatique : la résolution se fait par chemin uniquement.
f"Calendrier {self.calendar_name} introuvable et dry_run activé. " logger.warning(
"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 self._calendar = None
def _is_managed_event(self, event: DAVEvent) -> bool: def _is_managed_event(self, event: DAVEvent) -> bool:
"""Vérifie si un événement est géré par l'outil.""" """Vérifie si un événement est géré par l'outil."""
# Vérifier la présence du marqueur X-PRONOTE-SYNC-MANAGED # API réelle : event.icalendar_component (icalendar.Event)
props = event.properties vevent = event.icalendar_component
managed = props.get(self.MANAGED_PROPERTY, None) managed = vevent.get(self.MANAGED_PROPERTY)
return managed and managed.value == self.MANAGED_VALUE return managed is not None and str(managed) == self.MANAGED_VALUE
def _get_event_uid(self, event: DAVEvent) -> str: def _get_event_uid(self, event: DAVEvent) -> str:
"""Récupère l'UID normalisé d'un événement.""" """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) return normalize_pronote_uid(uid)
def _build_event( def _build_event(
self, self,
lesson: Lesson, lesson: Lesson,
calendar_name: Optional[str] = None,
) -> DAVEvent: ) -> DAVEvent:
"""Construit un événement CalDAV à partir d'un cours Pronote.""" """Construit un événement CalDAV à partir d'un cours Pronote."""
from icalendar import Event, vDatetime, vDate, vText, vUri from icalendar import Event, vDatetime, vDate, vText, vUri
@@ -2842,10 +2922,6 @@ class CalDAVClient:
categories.append("Déplacé") categories.append("Déplacé")
event.add("categories", categories) event.add("categories", categories)
# Nom du calendrier (si disponible)
if calendar_name:
event.add("x-wr-calname", calendar_name)
return DAVEvent(event) return DAVEvent(event)
def _build_homework_event(self, homework: Homework) -> DAVEvent: def _build_homework_event(self, homework: Homework) -> DAVEvent:
@@ -2901,7 +2977,12 @@ class CalDAVClient:
""" """
Compare les champs gérés pour déterminer si une mise à jour est nécessaire. 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 : 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: Args:
event1: Événement existant dans CalDAV. event1: Événement existant dans CalDAV.
@@ -2911,19 +2992,17 @@ class CalDAVClient:
True si les événements sont identiques pour les champs gérés, False sinon. True si les événements sont identiques pour les champs gérés, False sinon.
""" """
# Comparaison des UID normalisés # Comparaison des UID normalisés
uid1 = self._get_event_uid(event1) if self._get_event_uid(event1) != self._get_event_uid(event2):
uid2 = self._get_event_uid(event2)
if uid1 != uid2:
return False return False
# Comparaison des champs gérés # Comparaison des champs gérés (API réelle : icalendar_component)
vobj1 = event1.vobject_instance vobj1 = event1.icalendar_component
vobj2 = event2.vobject_instance vobj2 = event2.icalendar_component
# DTSTART et DTEND # DTSTART et DTEND
if vobj1.get("dtstart").value != vobj2.get("dtstart").value: if vobj1.get("dtstart").dt != vobj2.get("dtstart").dt:
return False return False
if vobj1.get("dtend").value != vobj2.get("dtend").value: if vobj1.get("dtend").dt != vobj2.get("dtend").dt:
return False return False
# SUMMARY # SUMMARY
@@ -2939,8 +3018,8 @@ class CalDAVClient:
return False return False
# CATEGORIES (comparaison des listes) # CATEGORIES (comparaison des listes)
cats1 = [str(c) for c in vobj1.get("categories", []).cats] if hasattr(vobj1.get("categories", None), "cats") else [] cats1 = [str(c) for c in vobj1.get("categories", [])]
cats2 = [str(c) for c in vobj2.get("categories", []).cats] if hasattr(vobj2.get("categories", None), "cats") else [] cats2 = [str(c) for c in vobj2.get("categories", [])]
if sorted(cats1) != sorted(cats2): if sorted(cats1) != sorted(cats2):
return False return False
@@ -3178,138 +3257,25 @@ class CalDAVClient:
self._calendar = None 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** : - **Scan du calendrier distant** : à chaque exécution, la synchronisation **scanne le
- **Permissions** : Appliquer `chmod 600` sur les fichiers d'état (ex: `.pronote_sync_state.json`) pour limiter l'accès au propriétaire. calendrier CalDAV distant** pour retrouver les événements gérés (marqueur
- **Exclusion Git** : Ajouter les fichiers d'état au `.gitignore` pour éviter de les commiter. `X-PRONOTE-SYNC-MANAGED: v1`), indexés par UID normalisé.
- **Exclusion des sauvegardes** : Exclure les fichiers dtat des sauvegardes automatiques (ex: Time Machine, rsync). - **Le calendrier distant est la source de vérité** : la comparaison entre événements
- **Emplacement** : Stocker les fichiers d'état dans un répertoire dédié (ex: `~/.config/pronote-sync/`) hors de l'arborescence Git. 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 > **Évolution future** : si les performances l'exigent (calendrier très chargé, scans
| **Option** | **Avantages** | **Inconvénients** | **Recommandation** | > 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
| Fichier JSON | Simple, portable, pas de dépendance | Moins performant pour les gros volumes | ✅ Pour un usage simple | > (§7.1, §7.2 et §7.4 restent valables).
| 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()
```
### 7.4 Points clés ### 7.4 Points clés
- **Différentielle** : La synchronisation compare les UID existants avec ceux à synchroniser. - **Différentielle** : La synchronisation compare les UID existants avec ceux à synchroniser.
@@ -3317,6 +3283,9 @@ class SyncState:
- **Dry-run** : Mode obligatoire pour tester sans effet de bord. - **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. - **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). - **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.
--- ---
@@ -3505,7 +3474,7 @@ def week_parity(
2. **Comparaison exacte** : Les créneaux horaires et la matière normalisée doivent correspondre. 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. 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 ```python
# Tri des cours théoriques par ID stable (pour un matching déterministe) # Tri des cours théoriques par ID stable (pour un matching déterministe)
theoretical_lessons_sorted = sorted( theoretical_lessons_sorted = sorted(
@@ -3517,24 +3486,43 @@ theoretical_lessons_sorted = sorted(
lesson.subject.lower(), lesson.subject.lower(),
), ),
) )
```
**Exemple de matching avec départage déterministe** : **Exemple de matching avec départage déterministe** :
```python ```python
def match_theoretical_lesson( def match_theoretical_lesson(
real_lesson: PronoteLesson, real_lesson: Lesson,
theoretical_lessons: list[TheoreticalLesson], theoretical_events: list[TheoreticalLesson],
tolerance_minutes: int = 15, tolerance_minutes: int = 15,
) -> TheoreticalLesson | None: ) -> TheoreticalLesson | None:
"""Trouve le cours théorique correspondant, avec départage déterministe.""" """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 = [ candidates = [
t for t in theoretical_lessons t
if t.day_of_week == real_lesson.start.weekday() for t in theoretical_events
and abs((t.start_time - real_lesson.start.time()).total_seconds()) <= tolerance_minutes * 60 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 normalize_subject(t.subject) == normalize_subject(real_lesson.subject)
] ]
if not candidates: if not candidates:
return None return None
# Tri déterministe par ID stable, puis par créneau
candidates.sort(key=lambda t: (t.id, t.start_time)) candidates.sort(key=lambda t: (t.id, t.start_time))
return candidates[0] return candidates[0]
``` ```

31
TODO.md
View File

@@ -114,14 +114,14 @@ Récupérer le flux RSS du blog du collège, parser et dédupliquer les articles
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. 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). - [x] Créer `sources/theoretical/provider.py` : protocole `TheoreticalAgendaProvider` (§8.2).
- [ ] 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/file.py` : parser JSON → liste de `TheoreticalLesson` avec filtrage par parité de semaine (paire/impaire/toutes).
- [ ] 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/parity.py` : service `WeekParityService` déterminant la parité d'une date à partir d'une date de référence configurée.
- [ ] Créer `sources/theoretical/holidays.py` : service `SchoolHolidayCalendar` lisant un fichier JSON de vacances scolaires (zone A) et exposant `is_holiday(date)`. - [x] Créer `sources/theoretical/holidays.py` : service `SchoolHolidayCalendar` lisant un fichier JSON de vacances scolaires (zone A) et exposant `is_holiday(date)`.
- [ ] Implémenter le provider JSON : filtrage par parité + vacances, génération d'identifiants déterministes incluant le type de semaine. - [x] Implémenter le provider JSON : filtrage par parité + vacances, génération d'identifiants déterministes incluant le type de semaine.
- [ ] Ajouter la configuration : `SCHOOL_HOLIDAYS_PATH`, `THEORETICAL_WEEK_ANCHOR_DATE`, `THEORETICAL_WEEK_ANCHOR_TYPE` dans `AppSettings`. - [x] Ajouter la configuration : `SCHOOL_HOLIDAYS_PATH`, `THEORETICAL_WEEK_ANCHOR_DATE`, `THEORETICAL_WEEK_ANCHOR_TYPE` dans `AppSettings`.
- [ ] Normaliser les matières et créneaux pour le matching déterministe. - [x] Normaliser les matières et créneaux pour le matching déterministe.
- [ ] Créer les fixtures : `tests/fixtures/theoretical.json` et `tests/fixtures/school_holidays.json`. - [x] Créer les fixtures : `tests/fixtures/theoretical.json` et `tests/fixtures/school_holidays.json`.
### Critères d'acceptation ### Critères d'acceptation
- `file.py` lit `tests/fixtures/theoretical.json` en `TheoreticalLesson` avec filtrage par parité. - `file.py` lit `tests/fixtures/theoretical.json` en `TheoreticalLesson` avec filtrage par parité.
@@ -136,19 +136,20 @@ Lire l'agenda théorique (JSON) via une interface de provider extensible, avec g
Synchroniser différentiellement les événements Pronote vers le calendrier CalDAV, de façon idempotente. 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/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`).
- [ ] 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, explicitement avant l'exécution de la sync.
- [ ] Calculer le `CalDAVSyncPlan` (to_add / to_update / to_remove) par UID stable. - [ ] Implémenter l'exécution du plan : ajout, mise à jour (si modifié), suppression (si absent). En mode `dry_run`, loguer le plan sans écrire.
- [ ] 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. - [ ] 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. - [ ] 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`). - [ ] Garantir l'idempotence (2 exécutions identiques → même `CalDAVSyncResult`), sans état local persistant (scan du calendrier distant).
- [ ] Réutiliser `BlogRSSState` pour l'état blog si pertinent (sinon `sync/blog_state.py`). - [ ] Ne jamais modifier ou supprimer les événements non marqués `X-PRONOTE-SYNC-MANAGED`.
### Critères d'acceptation ### 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 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. - 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 annulés restent (`STATUS:CANCELLED`) et sont marqués `MANAGED`.
- Les événements non marqués ne sont jamais modifiés ni supprimés.
--- ---

View File

@@ -10,6 +10,8 @@ plage de dates. Le filtrage tient compte du jour de la semaine, de la parité de
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
import unicodedata
from datetime import date, time, timedelta from datetime import date, time, timedelta
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
@@ -24,6 +26,25 @@ from pronote_sync.utils.redaction import redact_exception, redact_secrets
logger = logging.getLogger(__name__) 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: def _generate_id(entry: TheoreticalLessonEntry) -> str:
"""Génère un identifiant déterministe pour une entrée de cours. """Génère un identifiant déterministe pour une entrée de cours.
@@ -36,7 +57,7 @@ def _generate_id(entry: TheoreticalLessonEntry) -> str:
:return: Identifiant déterministe unique. :return: Identifiant déterministe unique.
:rtype: str :rtype: str
""" """
subject_slug = entry.subject.lower().strip().replace(" ", "-") subject_slug = normalize_subject(entry.subject).replace(" ", "-")
return f"theoretical:{entry.week}:{entry.day_of_week}:{entry.start_time}-{entry.end_time}:{subject_slug}" return f"theoretical:{entry.week}:{entry.day_of_week}:{entry.start_time}-{entry.end_time}:{subject_slug}"
@@ -111,7 +132,7 @@ class JsonTheoreticalAgendaProvider:
if effective_id in seen_ids: if effective_id in seen_ids:
raise PronoteSyncError( raise PronoteSyncError(
f"Conflit d'identifiant dans l'agenda théorique : " f"Conflit d'identifiant dans l'agenda théorique : "
f"l'identifiant '{effective_id}' est utilisé par plusieurs leçons. " f"l'identifiant '{redact_secrets(effective_id)}' est utilisé par plusieurs leçons. "
f"Fournissez des identifiants explicites uniques." f"Fournissez des identifiants explicites uniques."
) from None ) from None
seen_ids.add(effective_id) seen_ids.add(effective_id)

View File

@@ -13,7 +13,7 @@ from pathlib import Path
import pytest import pytest
from pronote_sync.errors import PronoteSyncError from pronote_sync.errors import PronoteSyncError
from pronote_sync.sources.theoretical.file import JsonTheoreticalAgendaProvider from pronote_sync.sources.theoretical.file import JsonTheoreticalAgendaProvider, normalize_subject
from pronote_sync.sources.theoretical.holidays import SchoolHolidayCalendar from pronote_sync.sources.theoretical.holidays import SchoolHolidayCalendar
from pronote_sync.sources.theoretical.parity import WeekParityService from pronote_sync.sources.theoretical.parity import WeekParityService
@@ -232,10 +232,10 @@ class TestJsonTheoreticalAgendaProvider:
lessons2 = provider_no_parity_no_holidays.get_lessons(target_date) lessons2 = provider_no_parity_no_holidays.get_lessons(target_date)
assert lessons1 == lessons2 assert lessons1 == lessons2
def test_even_odd_same_slot_different_ids( def test_even_odd_different_subjects_different_ids(
self, provider_with_parity: JsonTheoreticalAgendaProvider self, provider_with_parity: JsonTheoreticalAgendaProvider
) -> None: ) -> None:
"""Teste que les cours even/odd sur le même créneau ont des IDs différents. """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. :assert: Les IDs des cours even et odd sont différents.
""" """
@@ -257,6 +257,59 @@ class TestJsonTheoreticalAgendaProvider:
odd_ids = {lesson.id for lesson in odd_lessons} odd_ids = {lesson.id for lesson in odd_lessons}
assert even_ids.isdisjoint(odd_ids) 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( def test_explicit_id_preserved(
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
) -> None: ) -> None:
@@ -589,3 +642,81 @@ class TestJsonTheoreticalAgendaProvider:
# Seule la leçon du 15 octobre (jeudi) devrait être retournée # Seule la leçon du 15 octobre (jeudi) devrait être retournée
assert len(lessons) == 1 assert len(lessons) == 1
assert lessons[0].subject == "Sciences" 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__)