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>
This commit is contained in:
2026-09-07 00:00:54 +02:00
parent 958bb3ec5d
commit ebbe39f1f0
4 changed files with 149 additions and 179 deletions

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": 5065,
"line_number": 5034,
"is_secret": false
}
]
},
"generated_at": "2026-09-06T21:30:39Z"
"generated_at": "2026-09-06T22:00:48Z"
}

View File

@@ -2712,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
@@ -2748,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
@@ -2767,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
@@ -2842,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:
@@ -2901,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.
@@ -2911,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
@@ -2939,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
@@ -3178,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.
@@ -3317,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.
---

15
TODO.md
View File

@@ -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.
- [ ] 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.
---