From ebbe39f1f018cf3ba34925ba655ddeaecbada2ca Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Mon, 7 Sep 2026 00:00:54 +0200 Subject: [PATCH] docs(M7): aligner guide, TODO et pre-commit pour la sync CalDAV MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: opencode/tech-writer --- .pre-commit-config.yaml | 2 +- .secrets.baseline | 4 +- GUIDE_DEV_PYTHON.md | 307 ++++++++++++++++++---------------------- TODO.md | 15 +- 4 files changed, 149 insertions(+), 179 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 52dd3c6..6c6bbb8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/.secrets.baseline b/.secrets.baseline index dd0cbea..6e2cb4e 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -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" } diff --git a/GUIDE_DEV_PYTHON.md b/GUIDE_DEV_PYTHON.md index 37ac3a9..7b0bcf6 100644 --- a/GUIDE_DEV_PYTHON.md +++ b/GUIDE_DEV_PYTHON.md @@ -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"], - ) - else: - logger.warning( - f"Calendrier {self.calendar_name} introuvable et dry_run activé. " - "Aucune modification ne sera effectuée." - ) - # Créer un calendrier fictif pour les tests - self._calendar = None + # 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_path} introuvable" + f"{' et dry_run activé. Aucune modification ne sera effectuée.' if self.dry_run else ' : vérifier CalDAVSettings.calendar_path.'}" + ) + 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 d'état 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. --- diff --git a/TODO.md b/TODO.md index 50d10c2..e1114db 100644 --- a/TODO.md +++ b/TODO.md @@ -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. ---