+ ]]>
+
+
+ Sortie pédagogique
+ https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1626
+ https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1626
+ Tue, 11 Aug 2026 14:30:00 +0000
+ Pédagogie
+ Mme Martin
+ Sortie prévue au musée le 15 septembre.
+ Une sortie pédagogique au musée est prévue le 15 septembre.
+ ]]>
+
+
+
+```
+
+#### 5 bis.10.2 Tests unitaires
+
+**Test du parsing RSS** :
+```python
+import pytest
+from datetime import datetime, timezone
+from pronote_sync.sources.blog.rss import BlogRSSClient
+from pronote_sync.models.blog import BlogArticle
+
+
+@pytest.fixture
+def mock_blog_rss_client():
+ """Retourne un client RSS mocké pour les tests."""
+ client = BlogRSSClient(rss_url="file://tests/fixtures/blog_rss.xml")
+ return client
+
+
+@pytest.mark.unittest
+def test_parse_blog_rss(mock_blog_rss_client):
+ """Test le parsing d'un flux RSS du blog."""
+ articles = mock_blog_rss_client.fetch_and_parse()
+
+ assert len(articles) == 2
+
+ # Vérifier le premier article
+ article1 = articles[0]
+ assert article1.title == "Sortie pédagogique"
+ assert article1.url == "https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1626"
+ assert article1.category == "Pédagogie"
+ assert article1.author == "Mme Martin"
+ assert "musée" in article1.content_text
+ assert article1.published_at == datetime(2026, 8, 11, 14, 30, 0, tzinfo=timezone.utc)
+
+ # Vérifier le deuxième article
+ article2 = articles[1]
+ assert article2.title == "Réunion de rentrée"
+ assert article2.url == "https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1625"
+ assert article2.category == "Administration"
+ assert "1er septembre" in article2.content_text
+```
+
+**Test de la déduplication** :
+```python
+@pytest.mark.unittest
+def test_blog_deduplication(tmp_path):
+ """Test la déduplication des articles du blog."""
+ from pronote_sync.sync.blog_state import BlogRSSState
+
+ # Créer un fichier d'état temporaire
+ state_file = tmp_path / "blog_state.json"
+ state = BlogRSSState(state_file=str(state_file))
+
+ # Initialement, aucun article connu
+ assert state.get_last_guid() is None
+
+ # Simuler la récupération de 2 articles
+ state.update_last_guid("https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1625")
+ assert state.get_last_guid() == "https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1625"
+
+ # Simuler une nouvelle récupération : seul le nouvel article doit être retourné
+ client = BlogRSSClient(rss_url="file://tests/fixtures/blog_rss.xml")
+ new_articles = client.fetch_and_parse(last_guid=state.get_last_guid())
+
+ # Seul l'article avec p=1626 doit être retourné (car p=1625 est déjà connu)
+ assert len(new_articles) == 1
+ assert new_articles[0].id == "https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1626"
+```
+
+---
+
+### 5 bis.11 Intégration dans le message XMPP
+
+Les articles du blog sont intégrés dans la section **"Informations diverses"** du message XMPP :
+
+```python
+# Dans channels/xmpp.py, méthode _format_message
+def _format_message(self, message: XmppMessage) -> str:
+ lines = []
+
+ # ... sections existantes (synthèse, changements, devoirs) ...
+
+ # Informations diverses (blog + messages Pronote)
+ if message.external_info.blog_articles or message.external_info.pronote_messages:
+ lines.append("📢 Informations diverses :")
+
+ # Articles du blog
+ for article in message.external_info.blog_articles:
+ lines.append(f" - [{article.category or 'Info'}] {article.title} ({article.published_at.strftime('%d/%m')})")
+ lines.append(f" {article.content_text[:100]}...") # Extrait court
+ lines.append(f" 🔗 {article.url}")
+
+ # Messages Pronote
+ for msg in message.external_info.pronote_messages:
+ lines.append(f" - [Message] {msg.title} (de {msg.author})")
+
+ lines.append("")
+
+ return "\n".join(lines)
+```
+
+---
+
+### 5 bis.12 Points clés
+
+| **Aspect** | **Décision** | **Justification** |
+|--------------------------|-------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------|
+| **Flux RSS** | Utiliser `?feed=rss2` (pas `/feed/`). | Seule URL fonctionnelle sur WordPress Multisite. |
+| **Bibliothèque** | `feedparser` | Standard, gère RSS/Atom, compatible Python 3.13+. |
+| **Déduplication** | Par GUID (ou URL si GUID vide). | Éviter les doublons entre les exécutions. |
+| **Cache HTTP** | Utiliser `If-Modified-Since` / `If-None-Match` (géré par `feedparser`). | Limiter les requêtes inutiles. |
+| **Intégration XMPP** | Dans "Informations diverses" (avec les messages Pronote). | Cohérence avec l'objectif de synthèse globale. |
+| **Contenu** | Utiliser `` (HTML complet) + conversion en texte brut. | Le HTML contient toutes les informations (liens, images). |
+| **Sécurité** | Masquer les URLs dans les logs/erreurs. | Éviter les fuites de données (même si le blog est public). |
+| **Tests** | Fixtures XML anonymisées + mocks. | Pas de dépendance réseau, données reproductibles. |
+
+---
+
+### 5.1 Flux iCal Pronote
+
+#### 5.1.1 Observations sur les flux réels
+
+Les flux iCal générés par Pronote ont des **spécificités importantes** à prendre en compte, basées sur l'analyse du projet TypeScript `pronote-digest` :
+
+1. **Format des événements** :
+ - Les cours sont des événements **chronométrés** (`DTSTART` et `DTEND` avec heure, ex: `DTSTART:20260905T080000Z`).
+ - Les jours fériés/vacances sont des événements **tout le jour** (`DTSTART;VALUE=DATE`, ex: `DTSTART;VALUE=DATE:20260920`).
+
+2. **Catégories et statuts** :
+ - `CATEGORIES: Cours - Cours annulé` → Statut **annulé** (`STATUS:CANCELLED` dans iCal).
+ - `CATEGORIES: Cours - Cours déplacé` → Statut **déplacé** (à traiter comme une modification).
+ - `CATEGORIES: Congés` ou `Vacances` → Événement de type **vacances** (ex: `SUMMARY:Vacances de Noël`).
+
+3. **Description HTML** :
+ La `DESCRIPTION` contient des balises HTML avec des **labels en français** :
+ ```html
+
+ Matière : Mathématiques
+ Professeur : M. Dupont
+ Salle : 204
+ Groupe : Classe entière
+
+ Contenu pédagogique :
+
+ Résoudre des équations du second degré.
+ Pour le 10/09/2026 :
+
+ Exercices 1 à 5 page 42.
+ Donné le 05/09/2026 :
+
+ Exercices 1 à 5 page 42.
+
+ ```
+ - **Structure réelle** :
+ - La `DESCRIPTION` contient d'abord un **en-tête texte** (avant le premier ``) avec des lignes au format `Label : Valeur`.
+ - Les labels d'en-tête sont : `Matière :`, `Professeur :` ou `Professeurs :`, `Salle :` ou `Salles :`, `Groupe :`.
+ - Le **corps HTML** commence à partir du premier ``.
+ - Les sections sont identifiées par les balises exactes :
+ - `Contenu pédagogique : \n` → contenu pédagogique (texte brut).
+ - `Pour le JJ/MM/AAAA : \n` → devoir à faire (date d'échéance).
+ - `Donné le JJ/MM/AAAA : \n` → devoir donné (date d'attribution).
+ - **Devoirs en double** : Les devoirs apparaissent **deux fois** :
+ - Une fois sous `Pour le JJ/MM/AAAA` (date d'échéance).
+ - Une fois sous `Donné le JJ/MM/AAAA` (date de distribution).
+ - **Déduplication nécessaire** (voir [Section 5.1.4](#514-déduplication-des-devoirs)).
+
+4. **UID des événements** :
+ - Format réel observé : `Cours-16027-1-20260904T120218Z-Index-Education` (le préfixe peut varier : `Cours-...`, `Edt_...`, etc.).
+ - **Suffixes temporels** : Le suffixe `-YYYYMMDDTHHMMSSZ-Index-Education` (ou `-Index-Education` si pas de timestamp) change à chaque export.
+ - **Normalisation requise** : Supprimer le suffixe final `-YYYYMMDDTHHMMSSZ-Index-Education` ou `-Index-Education` pour obtenir un UID stable. Si aucun UID exploitable n'existe, produire un UID déterministe par hachage de champs clés (date de début, date de fin, matière, professeur, salle, groupe).
+
+5. **Nom du calendrier** :
+ - Lu depuis `X-WR-CALNAME` (ex: `X-WR-CALNAME:Edt Jean DUPONT 4ème A`).
+ - La bibliothèque `icalendar` ignore les propriétés `X-WR-*` avec paramètres → **lecture manuelle** du texte source (voir [Section 5.1.2](#512-récupération-du-nom-du-calendrier)).
+
+6. **Validation du flux** :
+ - Pronote renvoie du **HTML** (ex: page "Session expirée") si le token `icalsecurise` est invalide ou expiré.
+ - **Validation minimale** : Vérifier que le flux contient `BEGIN:VCALENDAR`.
+
+#### 5.1.2 Règles du jour cible
+
+La règle du jour cible est implémentée dans `src/core/calendar.ts` (fonction `resolveTarget`).
+Elle détermine la date pour laquelle générer le digest (planning ou devoirs).
+
+**3 cas exacts** :
+1. Si **J+1** a au moins un cours → `school-day` à J+1.
+2. Sinon, si **J** a des cours **ET** qu'il existe un prochain jour avec cours → `school-day` au prochain jour avec cours (ex: vendredi → lundi).
+3. Sinon → `no-school` à J+1, avec libellé de vacances si applicable, et date de reprise si connue.
+
+**Pseudocode** :
+```
+Si cours_existent(J+1) :
+ retourner J+1 (school-day)
+Sinon si cours_existent(J) ET prochain_jour_avec_cours_existe :
+ retourner prochain_jour_avec_cours (school-day)
+Sinon :
+ retourner J+1 (no-school, avec libellé de vacances si applicable)
+```
+
+**Exemple Python** :
+```python
+from typing import Optional, Tuple, List
+from datetime import date, timedelta
+from ..models.agenda import Lesson, SchoolEvent
+
+
+def resolve_target_day(
+ today: date,
+ lessons: List[Lesson],
+ school_events: List[SchoolEvent],
+) -> Tuple[date, str, Optional[date], Optional[str]]:
+ """
+ Détermine le jour cible pour le digest.
+
+ Args:
+ today: Date du jour.
+ lessons: Liste des cours.
+ school_events: Liste des événements scolaires (vacances).
+
+ Returns:
+ Tuple (date_cible, type, date_de_reprise, holiday_label).
+ - type : "school-day" ou "no-school".
+ - date_de_reprise : Date de reprise si en vacances, sinon None.
+ - holiday_label : Libellé des vacances si applicable, sinon None.
+ """
+ tomorrow = today + timedelta(days=1)
+
+ # Fonction helper pour vérifier si un jour a des cours
+ def has_lessons(day: date) -> bool:
+ return any(lesson.start.date() == day for lesson in lessons)
+
+ # Cas 1 : J+1 a des cours
+ if has_lessons(tomorrow):
+ return (tomorrow, "school-day", None, None)
+
+ # Cas 2 : J a des cours ET il existe un prochain jour avec cours
+ if has_lessons(today):
+ # Trouver le prochain jour avec cours après J (recherche illimitée)
+ next_day = today + timedelta(days=1)
+ while True:
+ if has_lessons(next_day):
+ return (next_day, "school-day", None, None)
+ next_day += timedelta(days=1)
+
+ # Cas 3 : Aucun cours à J+1 ou J → no-school
+ # Vérifier si J+1 est en vacances
+ holiday_label = None
+ resume_date = None
+ for event in school_events:
+ if event.kind == "holiday" and event.from_date <= tomorrow < event.to_date:
+ holiday_label = event.label
+ resume_date = event.to_date
+ break
+
+ return (tomorrow, "no-school", resume_date, holiday_label)
+```
+
+**Cas de test à couvrir** :
+| Scénario | Entrée (J) | Sortie attendue (date cible) | Type | Date de reprise | Libellé vacances |
+|-----------------------------------|------------------|-------------------------------|---------------|-----------------|------------------|
+| J+1 a des cours | Lundi | Mardi | school-day | None | None |
+| J a des cours, J+1 sans cours | Vendredi | Lundi | school-day | None | None |
+| J+1 en vacances | Veille de vacances | J+1 | no-school | Fin des vacances| "Vacances de Noël" |
+| J en vacances | Dimanche | Lundi | no-school | Fin des vacances| "Vacances de Noël" |
+| Aucune activité (week-end normal) | Samedi | Dimanche | no-school | None | None |
+
+
+#### 5.1.3 Récupération du flux iCal (`sources/pronote/ical.py`)
+
+```python
+import requests
+from typing import Optional
+from urllib.parse import urlparse
+from .redaction import redact_url, redact_secrets
+from ..models.agenda import RawCalendarData
+
+
+def fetch_ical(url: str, timeout: int = 20) -> str:
+ """
+ Récupère le flux iCal depuis une URL Pronote.
+ Inspiré de src/sources/pronote/fetch.ts.
+
+ Args:
+ url: URL du flux iCal (peut être file:// pour les tests).
+ timeout: Timeout en secondes (défaut: 20s).
+
+ Returns:
+ Contenu brut du flux iCal.
+
+ Raises:
+ ValueError: Si le flux est invalide (pas de BEGIN:VCALENDAR).
+ requests.exceptions.RequestException: En cas d'erreur HTTP.
+ """
+ headers = {
+ "accept": "text/calendar, */*;q=0.5",
+ "user-agent": "pronote-sync",
+ }
+
+ # Gestion des URLs file:// pour les tests
+ if url.startswith("file://"):
+ import pathlib
+ file_path = pathlib.Path(url.replace("file://", ""))
+ content = file_path.read_text(encoding="utf-8")
+ if "BEGIN:VCALENDAR" not in content:
+ raise ValueError(f"Fichier iCal invalide: {redact_url(url)}")
+ return content
+
+ # Récupération HTTP
+ try:
+ response = requests.get(
+ url,
+ headers=headers,
+ timeout=timeout,
+ )
+ response.raise_for_status()
+ content = response.text
+ except requests.exceptions.RequestException as e:
+ # Masquer l'URL et les détails de l'erreur
+ safe_url = redact_url(url)
+ safe_error = redact_secrets(str(e))
+ raise requests.exceptions.RequestException(
+ f"Échec de la récupération de {safe_url}: {safe_error}"
+ ) from e
+
+ # Validation du flux
+ if "BEGIN:VCALENDAR" not in content:
+ raise ValueError(
+ f"Flux iCal invalide (pas de BEGIN:VCALENDAR) pour {redact_url(url)}"
+ )
+
+ return content
+
+
+def get_calendar_name(raw_ical: str) -> Optional[str]:
+ """
+ Extrait le nom du calendrier depuis X-WR-CALNAME.
+
+ Args:
+ raw_ical: Contenu brut du flux iCal.
+
+ Returns:
+ Nom du calendrier ou None.
+ """
+ import re
+ # Recherche de X-WR-CALNAME (peut être sur une ligne ou plié)
+ match = re.search(r"X-WR-CALNAME:(.+?)(?:\r?\n|$)", raw_ical)
+ if match:
+ return match.group(1).strip()
+ return None
+```
+
+
+#### 5.1.4 Déduplication des devoirs
+
+Les devoirs apparaissent **deux fois** dans le flux iCal Pronote :
+- Une fois sous `Pour le JJ/MM/AAAA` (date d'échéance).
+- Une fois sous `Donné le JJ/MM/AAAA` (date de distribution).
+
+**Stratégie** (inspirée de `src/core/homework.ts`) :
+1. **Collecter tous les blocs homework** de tous les VEVENT d'abord.
+2. **Passe 1** : Parcourir **globalement** les blocs `Pour le` (devoirs à faire) dont la date correspond à la date d'échéance cible.
+3. **Passe 2** : Parcourir **globalement** les blocs `Donné le` sur les cours du jour cible (pour les flux sans `Pour le`).
+4. **Clé de déduplication** : Texte normalisé (`text.replace(/\s+/g, ' ').trim().toLowerCase()`).
+5. **ID stable** : `sha1([due_on, key]).slice(0, 12)`.
+6. **Tri** : Par matière puis texte (locale française).
+
+**Implémentation** :
+```python
+import re
+import hashlib
+from datetime import date
+from typing import Optional, List
+from ..models.agenda import Lesson
+from ..models.homework import Homework as HomeworkModel
+
+
+def normalize_homework_text(text: str) -> str:
+ """
+ Normalise le texte d'un devoir pour la déduplication.
+ Inspiré de src/core/homework.ts.
+
+ Args:
+ text: Texte brut du devoir.
+
+ Returns:
+ Texte normalisé (espaces unifiés, minuscules, sans balises HTML).
+ """
+ # Remplacer les espaces multiples par un seul
+ text = re.sub(r"\s+", " ", text)
+ # Supprimer les balises HTML
+ text = re.sub(r"<[^>]+>", "", text)
+ # Trim et minuscules
+ return text.strip().lower()
+
+
+def generate_homework_id(due_on: date, normalized_text: str) -> str:
+ """
+ Génère un ID stable pour un devoir.
+ Inspiré de src/core/homework.ts.
+
+ Args:
+ due_on: Date d'échéance (requise).
+ normalized_text: Texte normalisé du devoir.
+
+ Returns:
+ ID stable (12 premiers caractères du hash SHA-1).
+ """
+ due_on_str = due_on.isoformat()
+ key = f"{due_on_str}|{normalized_text}"
+ return hashlib.sha1(key.encode("utf-8")).hexdigest()[:12]
+
+
+def collect_homeworks(lessons: List[Lesson], target_date: date) -> List[HomeworkModel]:
+ """
+ Collecte et déduplique les devoirs en deux passes globales.
+ Inspiré de src/core/homework.ts.
+
+ Args:
+ lessons: Liste de tous les cours (VEVENT) parsés.
+ target_date: Date cible pour laquelle collecter les devoirs.
+
+ Returns:
+ Liste unique de devoirs, triée par matière puis texte.
+ """
+ by_text: dict[str, HomeworkModel] = {}
+
+ # Passe 1: blocs "Pour le" (due) — date d'échéance
+ for lesson in lessons:
+ for block in lesson.homework_blocks:
+ if block.kind == "due" and block.date == target_date:
+ key = normalize_homework_text(block.text)
+ if key not in by_text:
+ by_text[key] = HomeworkModel(
+ id=generate_homework_id(target_date, key),
+ subject=lesson.subject,
+ teachers=lesson.teachers,
+ assigned_on=lesson.start.date(),
+ due_on=block.date,
+ text=block.text,
+ html=block.html,
+ )
+
+ # Passe 2: blocs "Donné le" (assigned) — cours du jour cible
+ for lesson in lessons:
+ if not lesson.start.date() == target_date:
+ continue
+ for block in lesson.homework_blocks:
+ if block.kind == "assigned":
+ key = normalize_homework_text(block.text)
+ if key not in by_text:
+ by_text[key] = HomeworkModel(
+ id=generate_homework_id(target_date, key),
+ subject=lesson.subject,
+ teachers=lesson.teachers,
+ assigned_on=block.date,
+ due_on=target_date,
+ text=block.text,
+ html=block.html,
+ )
+
+ return sorted(by_text.values(), key=lambda h: (h.subject.lower(), h.text.lower()))
+```
+
+**Algorithme de déduplication** :
+- Les deux passes partagent la même `Map` (clé normalisée → devoir).
+- Un devoir présent dans `Pour le` **ET** `Donné le` est compté **une seule fois** (first-in-wins).
+- **Résultat** : Liste unique de devoirs, triée par matière puis texte.
+- **Note** : `Homework.due_on` est toujours requis (pour les blocs `Donné le`, on déduit `due_on = target_date`).
+
+**Intégration dans le pipeline** :
+La fonction `collect_homeworks(lessons, target_date)` est appelée **après le parsing** de tous les VEVENT,
+une fois que `target_date` est connu (via `resolve_target_day`).
+Cela permet de :
+1. Parcourir tous les cours pour extraire les blocs `Pour le` et `Donné le`.
+2. Dédupliquer globalement les devoirs en utilisant la clé normalisée.
+3. Retourner une liste unique de devoirs, triée par matière puis texte.
+
+**Exemple d'utilisation dans le pipeline** :
+```python
+# Après parsing de tous les VEVENT et résolution de target_date
+target_date = resolve_target_day(today, lessons, school_events)[0]
+homeworks = collect_homeworks(lessons, target_date)
+```
+
+
+#### 5.1.5 Normalisation des UID
+
+Les UID Pronote contiennent des **suffixes temporels** qui changent à chaque export. Exemple réel :
+```
+UID:Cours-16027-1-20260904T120218Z-Index-Education
+```
+
+**Solution** :
+1. Supprimer le suffixe final `-YYYYMMDDTHHMMSSZ-Index-Education` (ou `-Index-Education` si pas de timestamp).
+2. Si aucun UID exploitable n'existe, générer un UID déterministe par hachage des champs clés (date de début, date de fin, matière, professeur, salle, groupe).
+
+```python
+import re
+import hashlib
+from datetime import datetime
+from typing import Optional
+
+
+def normalize_pronote_uid(uid: str) -> str:
+ """
+ Normalise un UID Pronote en supprimant le suffixe temporel.
+ Inspiré de src/sources/pronote/parse.ts (lignes 161-164).
+
+ Args:
+ uid: UID brut de l'événement Pronote.
+
+ Returns:
+ UID stable sans suffixe temporel.
+ """
+ # Supprimer le suffixe -YYYYMMDDTHHMMSSZ-Index-Education ou -Index-Education
+ normalized = re.sub(r"-\d{8}T\d{6}Z-Index-Education$", "", uid)
+ normalized = re.sub(r"-Index-Education$", "", normalized)
+ return normalized
+
+
+def generate_deterministic_uid(
+ start: datetime,
+ end: datetime,
+ subject: str,
+ teachers: list[str],
+ rooms: list[str],
+ group: Optional[str] = None,
+) -> str:
+ """
+ Génère un UID déterministe si aucun UID exploitable n'existe.
+
+ Args:
+ start: Date/heure de début du cours.
+ end: Date/heure de fin du cours.
+ subject: Matière.
+ teachers: Liste des professeurs.
+ rooms: Liste des salles.
+ group: Groupe (optionnel).
+
+ Returns:
+ UID déterministe (hash SHA-1 des champs clés).
+ """
+ key_parts = [
+ start.isoformat(),
+ end.isoformat(),
+ subject,
+ ",".join(sorted(teachers)),
+ ",".join(sorted(rooms)),
+ group or "",
+ ]
+ key = "|".join(key_parts)
+ return hashlib.sha1(key.encode("utf-8")).hexdigest()[:12]
+
+
+#### 5.1.6 Parsing complet du flux iCal (`sources/pronote/ical.py`)
+
+```python
+from typing import List, Optional, Tuple
+from datetime import datetime, date
+from icalendar import Calendar, Event
+from ..models.agenda import Lesson, Homework, SchoolEvent, LessonStatus
+from ..models.homework import Homework as HomeworkModel
+
+
+def split_header_and_body(description: str) -> Tuple[str, str]:
+ """
+ Sépare l'en-tête texte du corps HTML dans la DESCRIPTION.
+ L'en-tête est avant le premier ``, le corps HTML commence à partir du premier ``.
+
+ Args:
+ description: Contenu brut de la DESCRIPTION.
+
+ Returns:
+ Tuple (en-tête texte, corps HTML).
+ """
+ # Trouver la position du premier ``
+ strong_start = description.find("")
+ if strong_start == -1:
+ return description, ""
+
+ header = description[:strong_start].strip()
+ body = description[strong_start:]
+ return header, body
+
+
+def parse_header(header: str) -> dict:
+ """
+ Parse l'en-tête texte pour extraire les métadonnées du cours.
+ Les labels sont : `Matière :`, `Professeur :`/`Professeurs :`, `Salle :`/`Salles :`, `Groupe :`.
+
+ Args:
+ header: En-tête texte (avant le premier ``).
+
+ Returns:
+ Dictionnaire avec les champs : subject, teachers, rooms, group.
+ """
+ import re
+ from html import unescape
+
+ result = {
+ "subject": "",
+ "teachers": [],
+ "rooms": [],
+ "group": None,
+ }
+
+ # Parser chaque ligne de l'en-tête (format : `Label : Valeur`)
+ for line in header.split("\n"):
+ line = line.strip()
+ if not line:
+ continue
+
+ # Extraire le label et la valeur
+ match = re.match(r"^([^:]+) :\s*(.+)$", line)
+ if not match:
+ continue
+
+ label = match.group(1).strip()
+ value = unescape(match.group(2).strip())
+
+ if label.lower() == "matière":
+ result["subject"] = value
+ elif label.lower() in ("professeur", "professeurs"):
+ # Split sur les virgules pour les professeurs multiples
+ result["teachers"] = [t.strip() for t in value.split(",") if t.strip()]
+ elif label.lower() in ("salle", "salles"):
+ # Split sur les virgules pour les salles multiples
+ result["rooms"] = [r.strip() for r in value.split(",") if r.strip()]
+ elif label.lower() == "groupe":
+ result["group"] = value
+
+ return result
+
+
+def parse_body(body: str) -> Tuple[Optional[str], List[dict]]:
+ """
+ Parse le corps HTML pour extraire le contenu pédagogique et les devoirs.
+
+ Args:
+ body: Corps HTML (à partir du premier ``).
+
+ Returns:
+ Tuple (contenu pédagogique, liste des devoirs).
+ """
+ import re
+ from html import unescape
+
+ content = None
+ homeworks = []
+
+ # Extraire le contenu pédagogique
+ content_match = re.search(
+ r"Contenu pédagogique : \n(.+?)(?:|$)",
+ body,
+ re.DOTALL,
+ )
+ if content_match:
+ content_html = content_match.group(1).strip()
+ # Nettoyer les balises HTML pour le texte brut
+ content = re.sub(r"<[^>]+>", "", content_html)
+ content = unescape(content).strip()
+
+ # Extraire les devoirs (blocs "Pour le" et "Donné le")
+ # Passe 1 : blocs "Pour le JJ/MM/AAAA" (date d'échéance)
+ pour_le_matches = re.finditer(
+ r"Pour le (\d{2}/\d{2}/\d{4}) : \n(.+?)(?:|$)",
+ body,
+ re.DOTALL,
+ )
+
+ for match in pour_le_matches:
+ due_date_str = match.group(1)
+ text_html = match.group(2).strip()
+
+ # Nettoyer le texte pour la clé de déduplication
+ text_clean = re.sub(r"<[^>]+>", "", text_html)
+ text_clean = unescape(text_clean).strip()
+
+ # Parser la date (format JJ/MM/AAAA → AAAA-MM-JJ)
+ try:
+ due_date = datetime.strptime(due_date_str, "%d/%m/%Y").date()
+ except ValueError:
+ continue
+
+ homeworks.append({
+ "type": "due",
+ "date": due_date,
+ "text": text_clean,
+ "html": text_html,
+ })
+
+ # Passe 2 : blocs "Donné le JJ/MM/AAAA" (date d'attribution)
+ donne_le_matches = re.finditer(
+ r"Donné le (\d{2}/\d{2}/\d{4}) : \n(.+?)(?:|$)",
+ body,
+ re.DOTALL,
+ )
+
+ for match in donne_le_matches:
+ assigned_date_str = match.group(1)
+ text_html = match.group(2).strip()
+
+ # Nettoyer le texte
+ text_clean = re.sub(r"<[^>]+>", "", text_html)
+ text_clean = unescape(text_clean).strip()
+
+ # Parser la date
+ try:
+ assigned_date = datetime.strptime(assigned_date_str, "%d/%m/%Y").date()
+ except ValueError:
+ continue
+
+ homeworks.append({
+ "type": "assigned",
+ "date": assigned_date,
+ "text": text_clean,
+ "html": text_html,
+ })
+
+ return content, homeworks
+
+
+def parse_homework_blocks(body: str) -> List[dict]:
+ """
+ Parse le corps HTML pour extraire les blocs de devoirs (Pour le / Donné le).
+
+ Args:
+ body: Corps HTML (à partir du premier ``).
+
+ Returns:
+ Liste des blocs de devoirs avec type, date, texte et HTML.
+ """
+ homeworks = []
+
+ # Passe 1 : blocs "Pour le JJ/MM/AAAA" (date d'échéance)
+ pour_le_matches = re.finditer(
+ r"Pour le (\d{2}/\d{2}/\d{4}) : \n(.+?)(?:|$)",
+ body,
+ re.DOTALL,
+ )
+
+ for match in pour_le_matches:
+ due_date_str = match.group(1)
+ text_html = match.group(2).strip()
+
+ # Nettoyer le texte pour la clé de déduplication
+ text_clean = re.sub(r"<[^>]+>", "", text_html)
+ text_clean = unescape(text_clean).strip()
+
+ # Parser la date (format JJ/MM/AAAA → AAAA-MM-JJ)
+ try:
+ due_date = datetime.strptime(due_date_str, "%d/%m/%Y").date()
+ except ValueError:
+ continue
+
+ homeworks.append({
+ "type": "due",
+ "date": due_date,
+ "text": text_clean,
+ "html": text_html,
+ })
+
+ # Passe 2 : blocs "Donné le JJ/MM/AAAA" (date d'attribution)
+ donne_le_matches = re.finditer(
+ r"Donné le (\d{2}/\d{2}/\d{4}) : \n(.+?)(?:|$)",
+ body,
+ re.DOTALL,
+ )
+
+ for match in donne_le_matches:
+ assigned_date_str = match.group(1)
+ text_html = match.group(2).strip()
+
+ # Nettoyer le texte
+ text_clean = re.sub(r"<[^>]+>", "", text_html)
+ text_clean = unescape(text_clean).strip()
+
+ # Parser la date
+ try:
+ assigned_date = datetime.strptime(assigned_date_str, "%d/%m/%Y").date()
+ except ValueError:
+ continue
+
+ homeworks.append({
+ "type": "assigned",
+ "date": assigned_date,
+ "text": text_clean,
+ "html": text_html,
+ })
+
+ return homeworks
+
+
+def parse_ical(raw_ical: str) -> tuple[List[Lesson], List[HomeworkModel], List[SchoolEvent]]:
+ """
+ Parse un flux iCal Pronote en événements typés.
+
+ Args:
+ raw_ical: Contenu brut du flux iCal.
+
+ Returns:
+ Tuple (lessons, homeworks, school_events).
+ - lessons : Liste des cours avec leurs blocs de devoirs bruts (homework_blocks).
+ - homeworks : **Toujours vide** (la collecte/déduplication se fait plus tard dans le pipeline via `collect_homeworks(lessons, target_date)`).
+ - school_events : Liste des événements scolaires (vacances).
+
+ **Note importante** :
+ La déduplication globale des devoirs est effectuée **après le parsing** de tous les VEVENT,
+ une fois que `target_date` est connu (via `resolve_target_day`).
+ Voir la section [5.1.4 Déduplication des devoirs](#514-déduplication-des-devoirs) pour plus de détails.
+ """
+ cal = Calendar.from_ical(raw_ical)
+
+ lessons: List[Lesson] = []
+ homeworks: List[HomeworkModel] = [] # Toujours vide : la collecte se fait via collect_homeworks(lessons, target_date)
+ school_events: List[SchoolEvent] = []
+
+ for component in cal.walk():
+ if not isinstance(component, Event):
+ continue
+
+ # Déterminer le type d'événement
+ categories = getattr(component, "categories", None)
+ if categories:
+ categories = [c.to_unicode() for c in categories.cats]
+ else:
+ categories = []
+
+ # Événements de type "vacances"
+ if any(cat in ["Congés", "Vacances"] for cat in categories):
+ school_events.append(SchoolEvent(
+ kind="holiday",
+ label=str(component.get("summary")),
+ from_date=component.get("dtstart").dt,
+ to_date=component.get("dtend").dt,
+ ))
+ continue
+
+ # Cours annulés ou déplacés
+ status = LessonStatus.NORMAL
+ if "Cours - Cours annulé" in categories:
+ status = LessonStatus.CANCELLED
+ elif "Cours - Cours déplacé" in categories:
+ status = LessonStatus.MOVED
+
+ # Parsing de la description
+ description = str(component.get("description", ""))
+ header, body = split_header_and_body(description)
+
+ # Parser l'en-tête pour les métadonnées du cours
+ lesson_data = parse_header(header)
+
+ # Parser le corps pour le contenu et les devoirs
+ content, raw_homeworks = parse_body(body)
+
+ # Créer le cours avec les blocs de devoirs bruts (pour déduplication globale)
+ start = component.get("dtstart").dt
+ end = component.get("dtend").dt
+ uid = normalize_pronote_uid(str(component.get("uid")))
+
+ lesson = Lesson(
+ id=uid,
+ start=start,
+ end=end,
+ subject=lesson_data.get("subject", ""),
+ teachers=lesson_data.get("teachers", []),
+ rooms=lesson_data.get("rooms", []),
+ group=lesson_data.get("group"),
+ status=status,
+ content=content,
+ homework_blocks=raw_homeworks, # Stockage temporaire pour déduplication globale
+ )
+ lessons.append(lesson)
+
+ return lessons, homeworks, school_events
+```
+
+
+#### 5.1.7 Client `pronotepy` pour messages et informations
+
+`pronotepy` est utilisé **uniquement** pour :
+- Les **messages** des professeurs (`client.get_discussions()`).
+- Les **informations et sondages** (`client.get_information_and_surveys()`).
+
+```python
+from typing import List, Optional
+from pronotepy import Client, PronoteAPIError
+from ..models.message import Message, MessageType
+from ..redaction import redact_url
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class PronoteClient:
+ """Client pour interagir avec Pronote via pronotepy."""
+
+ def __init__(
+ self,
+ username: Optional[str] = None,
+ password: Optional[str] = None,
+ ent: Optional[str] = None,
+ ical_url: Optional[str] = None,
+ ):
+ self.username = username
+ self.password = password
+ self.ent = ent
+ self.ical_url = ical_url
+ self._client: Optional[Client] = None
+
+ def _get_client(self) -> Client:
+ """Initialise et retourne le client pronotepy."""
+ if self._client is None:
+ if not all([self.username, self.password, self.ent]):
+ raise ValueError("Username, password et ENT sont requis pour pronotepy")
+
+ self._client = Client(
+ self.username,
+ self.password,
+ self.ent,
+ )
+ return self._client
+
+ def get_messages(self) -> List[Message]:
+ """Récupère les messages des professeurs."""
+ try:
+ client = self._get_client()
+ discussions = client.get_discussions()
+
+ messages = []
+ for discussion in discussions:
+ for message in discussion.messages:
+ messages.append(Message(
+ id=str(message.id),
+ type=MessageType.DISCUSSION,
+ title=message.title,
+ content=message.content,
+ author=message.author,
+ date=message.date,
+ read=message.is_read,
+ ))
+ return messages
+ except PronoteAPIError as e:
+ logger.error(f"Échec de la récupération des messages Pronote: {redact_secrets(str(e))}")
+ return []
+
+ def get_informations(self) -> List[Message]:
+ """Récupère les informations et sondages."""
+ try:
+ client = self._get_client()
+ informations = client.get_information_and_surveys()
+
+ messages = []
+ for info in informations:
+ messages.append(Message(
+ id=str(info.id),
+ type=MessageType.INFORMATION,
+ title=info.title,
+ content=info.content,
+ author=info.author,
+ date=info.date,
+ read=False, # Par défaut non lu
+ ))
+ return messages
+ except PronoteAPIError as e:
+ logger.error(f"Échec de la récupération des informations Pronote: {redact_secrets(str(e))}")
+ return []
+
+ def get_agenda_fallback(self) -> tuple[List[Lesson], List[HomeworkModel]]:
+ """
+ Récupère l'agenda et les devoirs via pronotepy (repli si iCal échoue).
+ **À utiliser uniquement si PRONOTE_AGENDA_SOURCE=pronotepy ou PRONOTE_HOMEWORK_SOURCE=pronotepy**.
+ """
+ try:
+ client = self._get_client()
+
+ lessons = []
+ for lesson in client.get_lessons():
+ lessons.append(Lesson(
+ id=str(lesson.id),
+ start=lesson.start,
+ end=lesson.end,
+ subject=lesson.subject,
+ teachers=[t.name for t in lesson.teachers],
+ rooms=[r.name for r in lesson.rooms],
+ status=LessonStatus.NORMAL, # À adapter selon les données
+ content=lesson.content,
+ ))
+
+ homeworks = []
+ for hw in client.get_homework():
+ homeworks.append(HomeworkModel(
+ id=str(hw.id),
+ subject=hw.subject,
+ teachers=[t.name for t in hw.teachers],
+ assigned_on=hw.given_date,
+ due_on=hw.due_date,
+ text=hw.description,
+ html=hw.description, # pronotepy ne fournit pas de HTML
+ ))
+
+ return lessons, homeworks
+ except PronoteAPIError as e:
+ logger.error(f"Échec de la récupération de l'agenda via pronotepy: {redact_secrets(str(e))}")
+ return [], []
+
+ def close(self) -> None:
+ """Fermeture du client."""
+ if self._client:
+ self._client.close()
+ self._client = None
+
+
+#### 5.1.8 Logique de repli (`sources/pronote/fallback.py`)
+
+```python
+from typing import Literal, Optional
+from enum import Enum
+from .ical import fetch_ical, parse_ical
+from .client import PronoteClient
+from ..models.agenda import Lesson, Homework
+
+
+class AgendaSource(Enum):
+ AUTO = "auto"
+ ICAL = "ical"
+ PRONOTEPY = "pronotepy"
+
+
+class PronoteFetcher:
+ """Gère la récupération des données Pronote avec repli."""
+
+ def __init__(
+ self,
+ ical_url: Optional[str] = None,
+ username: Optional[str] = None,
+ password: Optional[str] = None,
+ ent: Optional[str] = None,
+ agenda_source: str = "auto",
+ homework_source: str = "auto",
+ ):
+ self.ical_url = ical_url
+ self.username = username
+ self.password = password
+ self.ent = ent
+ self.agenda_source = AgendaSource(agenda_source)
+ self.homework_source = AgendaSource(homework_source)
+ self._pronote_client: Optional[PronoteClient] = None
+
+ def _get_pronote_client(self) -> PronoteClient:
+ if self._pronote_client is None:
+ self._pronote_client = PronoteClient(
+ username=self.username,
+ password=self.password,
+ ent=self.ent,
+ ical_url=self.ical_url,
+ )
+ return self._pronote_client
+
+ def fetch_agenda(self) -> tuple[List[Lesson], List[Homework]]:
+ """Récupère l'agenda selon la source configurée (`agenda_source`)."""
+ if self.agenda_source == AgendaSource.ICAL:
+ return self._fetch_agenda_ical()
+ elif self.agenda_source == AgendaSource.PRONOTEPY:
+ return self._fetch_agenda_pronotepy()
+ else: # AUTO
+ # Essayer iCal d'abord
+ try:
+ lessons, homeworks = self._fetch_agenda_ical()
+ if lessons or homeworks:
+ return lessons, homeworks
+ except Exception as e:
+ logger.warning(f"Échec de la récupération iCal pour l'agenda: {redact_secrets(str(e))}")
+
+ # Repli sur pronotepy
+ logger.info("Repli sur pronotepy pour l'agenda.")
+ return self._fetch_agenda_pronotepy()
+
+ def fetch_homework(self) -> List[Homework]:
+ """Récupère les devoirs selon la source configurée (`homework_source`)."""
+ if self.homework_source == AgendaSource.ICAL:
+ # Récupérer uniquement les devoirs depuis iCal
+ try:
+ _, homeworks = self._fetch_agenda_ical()
+ return homeworks
+ except Exception as e:
+ logger.warning(f"Échec de la récupération iCal pour les devoirs: {redact_secrets(str(e))}")
+ return []
+ elif self.homework_source == AgendaSource.PRONOTEPY:
+ # Récupérer uniquement les devoirs depuis pronotepy
+ try:
+ _, homeworks = self._fetch_agenda_pronotepy()
+ return homeworks
+ except Exception as e:
+ logger.warning(f"Échec de la récupération pronotepy pour les devoirs: {redact_secrets(str(e))}")
+ return []
+ else: # AUTO
+ # Essayer iCal d'abord
+ try:
+ _, homeworks = self._fetch_agenda_ical()
+ if homeworks:
+ return homeworks
+ except Exception as e:
+ logger.warning(f"Échec de la récupération iCal pour les devoirs: {redact_secrets(str(e))}")
+
+ # Repli sur pronotepy
+ logger.info("Repli sur pronotepy pour les devoirs.")
+ try:
+ _, homeworks = self._fetch_agenda_pronotepy()
+ return homeworks
+ except Exception as e:
+ logger.warning(f"Échec de la récupération pronotepy pour les devoirs: {redact_secrets(str(e))}")
+ return []
+
+ def _fetch_agenda_ical(self) -> tuple[List[Lesson], List[Homework]]:
+ """Récupère l'agenda depuis iCal."""
+ if not self.ical_url:
+ raise ValueError("PRONOTE_ICAL_URL est requis pour la source iCal")
+
+ raw_ical = fetch_ical(self.ical_url)
+ lessons, homeworks, _ = parse_ical(raw_ical)
+ return lessons, homeworks
+
+ def _fetch_agenda_pronotepy(self) -> tuple[List[Lesson], List[Homework]]:
+ """Récupère l'agenda depuis pronotepy."""
+ client = self._get_pronote_client()
+ lessons, homeworks = client.get_agenda_fallback()
+ return lessons, homeworks
+
+ def fetch_messages(self) -> List[Message]:
+ """Récupère les messages (toujours via pronotepy)."""
+ client = self._get_pronote_client()
+ return client.get_messages()
+
+ def fetch_informations(self) -> List[Message]:
+ """Récupère les informations (toujours via pronotepy)."""
+ client = self._get_pronote_client()
+ return client.get_informations()
+
+ def close(self) -> None:
+ """Fermeture des ressources."""
+ if self._pronote_client:
+ self._pronote_client.close()
+ self._pronote_client = None
+```
+
+
+### 5.2 Résumé des points clés
+
+| **Aspect** | **iCal** | **pronotepy** | **Recommandation** |
+|--------------------------|-----------------------------------|-----------------------------------|--------------------------------------------|
+| **Agenda** | ✅ Disponible | ✅ Disponible | Préférer iCal (officiel, stable). |
+| **Devoirs** | ✅ Si activé par l'établissement | ✅ Toujours disponible | Préférer iCal si disponible. |
+| **Messages** | ❌ Non disponible | ✅ Disponible | Utiliser pronotepy. |
+| **Informations** | ❌ Non disponible | ✅ Disponible | Utiliser pronotepy. |
+| **Stabilité** | ✅ Très stable | ⚠️ Peut casser (reverse-engineering) | iCal en priorité. |
+| **Authentification** | ❌ Pas besoin (token dans URL) | ✅ Nécessaire (login/mot de passe) | Masquer les secrets. |
+| **Performances** | ✅ Rapide (1 requête HTTP) | ⚠️ Plus lent (plusieurs requêtes) | iCal en priorité. |
+
+---
+
+## 6. Modèle de données Pydantic
+
+### 6.1 Principes
+- **Modèles distincts par domaine** : Ne pas créer un unique modèle fourre-tout. Chaque étape du pipeline utilise des modèles dédiés (décision architecturale).
+- **Validation stricte** : Utiliser Pydantic pour valider les données dès leur création.
+- **Immuabilité** : Les modèles de **contrat** (ex: `Lesson`, `Homework`, `Message`) doivent être immuables (`frozen=True`). Les modèles de **travail** (ex: `CalDAVSyncResult`, `PronoteData`) peuvent être mutables pour permettre les mises à jour progressives.
+- **Sérialisation** : Tous les modèles doivent supporter la sérialisation JSON (pour l'archivage et les tests).
+
+### 6.2 Modèles de base (`models/__init__.py`)
+
+```python
+from datetime import datetime, date, time
+from typing import List, Optional, Literal
+from enum import Enum
+from pydantic import BaseModel, Field, validator
+
+
+# --- Types de base ---
+
+class Status(str, Enum):
+ """Statut générique pour les événements."""
+ NORMAL = "normal"
+ CANCELLED = "cancelled"
+ MOVED = "moved"
+
+
+# --- Modèles d'agenda ---
+
+class LessonStatus(str, Enum):
+ """Statut d'un cours."""
+ NORMAL = "normal"
+ CANCELLED = "cancelled"
+ MOVED = "moved"
+
+
+class HomeworkBlock(BaseModel):
+ """
+ Représente un bloc de devoir extrait de la description d'un cours.
+ Utilisé pour la déduplication globale des devoirs.
+ """
+ kind: Literal["due", "assigned"] = Field(..., description="Type de bloc (échéance ou attribution)")
+ date: date = Field(..., description="Date associée au bloc")
+ text: str = Field(..., description="Texte du devoir (brut)")
+ html: str = Field(default="", description="Texte du devoir (HTML)")
+
+
+class Lesson(BaseModel):
+ """
+ Représente un cours dans l'agenda Pronote.
+ Équivalent de `Lesson` dans src/core/model.ts.
+ """
+ id: str = Field(..., description="UID stable du cours (normalisé)")
+ start: datetime = Field(..., description="Date/heure de début")
+ end: datetime = Field(..., description="Date/heure de fin")
+ subject: str = Field(..., description="Matière (ex: Mathématiques)")
+ teachers: List[str] = Field(default_factory=list, description="Liste des professeurs")
+ rooms: List[str] = Field(default_factory=list, description="Liste des salles")
+ group: Optional[str] = Field(None, description="Groupe (ex: Classe entière)")
+ status: LessonStatus = Field(LessonStatus.NORMAL, description="Statut du cours")
+ content: Optional[str] = Field(None, description="Contenu pédagogique")
+ homework_blocks: List[HomeworkBlock] = Field(
+ default_factory=list, description="Blocs de devoirs extraits de la description"
+ )
+
+ class Config:
+ frozen = True # Immuable
+ json_encoders = {
+ datetime: lambda v: v.isoformat(),
+ }
+
+
+class SchoolEventKind(str, Enum):
+ """Type d'événement scolaire."""
+ HOLIDAY = "holiday"
+ PUBLIC_HOLIDAY = "public_holiday"
+
+
+class SchoolEvent(BaseModel):
+ """
+ Représente un événement scolaire (vacances, jours fériés).
+ Équivalent de `SchoolEvent` dans src/core/model.ts.
+ """
+ kind: SchoolEventKind = Field(..., description="Type d'événement")
+ label: str = Field(..., description="Libellé (ex: Vacances de Noël)")
+ from_date: date = Field(..., description="Date de début (inclusive)")
+ to_date: date = Field(..., description="Date de fin (exclusive)")
+
+ class Config:
+ frozen = True
+
+
+class TheoreticalLesson(BaseModel):
+ """
+ Représente un cours dans l'agenda théorique.
+ Utilisé pour la comparaison avec l'agenda réel.
+ """
+ id: str = Field(..., description="Identifiant unique")
+ day_of_week: int = Field(..., description="Jour de la semaine (0=lundi, 6=dimanche)")
+ start_time: time = Field(..., description="Heure de début")
+ end_time: time = Field(..., description="Heure de fin")
+ subject: str = Field(..., description="Matière")
+ teachers: List[str] = Field(default_factory=list, description="Liste des professeurs")
+ rooms: List[str] = Field(default_factory=list, description="Liste des salles")
+
+ class Config:
+ frozen = True
+
+
+# --- Modèles de devoirs ---
+
+class Homework(BaseModel):
+ """
+ Représente un devoir.
+ Équivalent de `Homework` dans src/core/model.ts.
+ """
+ id: str = Field(..., description="ID stable (hachage)")
+ subject: str = Field(..., description="Matière")
+ teachers: List[str] = Field(default_factory=list, description="Liste des professeurs")
+ assigned_on: Optional[date] = Field(None, description="Date de distribution")
+ due_on: date = Field(..., description="Date d'échéance")
+ text: str = Field(..., description="Texte du devoir (brut)")
+ html: str = Field(default="", description="Texte du devoir (HTML)")
+
+ class Config:
+ frozen = True
+
+
+# --- Modèles de messages ---
+
+class MessageType(str, Enum):
+ """Type de message Pronote."""
+ DISCUSSION = "discussion"
+ INFORMATION = "information"
+ SURVEY = "survey"
+
+
+class Message(BaseModel):
+ """
+ Représente un message ou une information Pronote.
+ """
+ id: str = Field(..., description="Identifiant unique")
+ type: MessageType = Field(..., description="Type de message")
+ title: str = Field(..., description="Titre")
+ content: str = Field(..., description="Contenu")
+ author: str = Field(..., description="Auteur")
+ date: datetime = Field(..., description="Date de création")
+ read: bool = Field(False, description="Lu ou non")
+
+ class Config:
+ frozen = True
+
+
+# --- Modèles de comparaison ---
+
+class AgendaChangeType(str, Enum):
+ """Type de changement dans l'agenda."""
+ ADDED = "added"
+ REMOVED = "removed"
+ MODIFIED = "modified"
+
+
+class AgendaChange(BaseModel):
+ """
+ Représente un changement entre l'agenda réel et l'agenda théorique.
+ """
+ type: AgendaChangeType = Field(..., description="Type de changement")
+ lesson: Optional[Lesson] = Field(None, description="Cours concerné (pour ADDED/MODIFIED)")
+ theoretical_lesson: Optional[TheoreticalLesson] = Field(
+ None, description="Cours théorique concerné (pour REMOVED/MODIFIED)"
+ )
+ details: str = Field(default="", description="Détails du changement")
+
+ class Config:
+ frozen = True
+
+
+class AgendaDiff(BaseModel):
+ """
+ Représente les différences entre l'agenda réel et l'agenda théorique.
+ """
+ target_date: date = Field(..., description="Date cible de la comparaison")
+ changes: List[AgendaChange] = Field(default_factory=list, description="Liste des changements")
+
+ class Config:
+ frozen = True
+
+
+# --- Modèle XMPP ---
+
+class XmppMessage(BaseModel):
+ """
+ Représente le message final à envoyer par XMPP.
+ **Sépare clairement la synthèse IA et la liste brute des devoirs** (décision [5](#5-synthèse-ia---protocole-pas-de-sdk-imposé)).
+ """
+ target_date: date = Field(..., description="Date cible")
+ synthesis: Optional[str] = Field(
+ None,
+ description="Synthèse IA (optionnelle). 3-5 phrases, ton chaleureux et sobre."
+ )
+ homeworks: List[Homework] = Field(
+ default_factory=list,
+ description="Liste **brute** des devoirs (non modifiée par l'IA)"
+ )
+ changes: List[AgendaChange] = Field(
+ default_factory=list,
+ description="Liste des changements d'agenda"
+ )
+ messages: List[Message] = Field(
+ default_factory=list,
+ description="Liste des messages/informations importants"
+ )
+ external_info: ExternalInfo = Field(
+ default_factory=ExternalInfo,
+ description="Informations externes (blog, messages Pronote)"
+ )
+
+ class Config:
+ frozen = True
+
+
+# --- Modèle de sync CalDAV ---
+
+class CalDAVSyncStatus(str, Enum):
+ """Statut de synchronisation CalDAV."""
+ SUCCESS = "success"
+ FAILED = "failed"
+ SKIPPED = "skipped" # Déjà synchronisé
+
+
+# --- Modèles de données normalisées (Pronote) ---
+
+class PronoteData(BaseModel):
+ """
+ Données normalisées récupérées depuis Pronote.
+ Résultat de l'étape de récupération et normalisation.
+ """
+ lessons: List[Lesson] = Field(default_factory=list, description="Liste des cours")
+ homeworks: List[Homework] = Field(default_factory=list, description="Liste des devoirs")
+ school_events: List[SchoolEvent] = Field(
+ default_factory=list,
+ description="Liste des événements scolaires"
+ )
+ messages: List[Message] = Field(
+ default_factory=list,
+ description="Liste des messages/informations"
+ )
+ target_date: date = Field(..., description="Date cible (J+1 ou prochain jour scolaire)")
+ generated_at: datetime = Field(..., description="Date/heure de génération")
+
+ class Config:
+ json_encoders = {
+ datetime: lambda v: v.isoformat(),
+ date: lambda v: v.isoformat(),
+ }
+
+
+# --- Modèles de synchronisation CalDAV ---
+
+class CalDAVSyncPlan(BaseModel):
+ """
+ Plan de synchronisation CalDAV.
+ Contient les événements à ajouter, modifier ou supprimer.
+ """
+ lessons_to_add: List[Lesson] = Field(default_factory=list)
+ lessons_to_update: List[Lesson] = Field(default_factory=list)
+ lessons_to_remove: List[str] = Field(default_factory=list) # Liste d'UID
+ homeworks_to_add: List[Homework] = Field(default_factory=list)
+ homeworks_to_update: List[Homework] = Field(default_factory=list)
+ homeworks_to_remove: List[str] = Field(default_factory=list) # Liste d'UID
+ school_events_to_add: List[SchoolEvent] = Field(default_factory=list)
+ school_events_to_update: List[SchoolEvent] = Field(default_factory=list)
+ school_events_to_remove: List[str] = Field(default_factory=list) # Liste d'UID
+
+
+class CalDAVSyncResult(BaseModel):
+ """
+ Résultat d'une synchronisation CalDAV.
+ **Mutable** : les compteurs sont incrémentés pendant la synchronisation.
+ """
+ status: CalDAVSyncStatus = Field(..., description="Statut global")
+ added: int = Field(0, description="Nombre d'événements ajoutés")
+ updated: int = Field(0, description="Nombre d'événements mis à jour")
+ removed: int = Field(0, description="Nombre d'événements supprimés")
+ errors: List[str] = Field(default_factory=list, description="Liste des erreurs")
+
+
+# --- Modèles de synthèse IA ---
+
+class SynthesisInput(BaseModel):
+ """
+ Entrée pour la synthèse IA.
+ Contient les données nécessaires à la génération de la synthèse.
+ """
+ agenda_diff: Optional[AgendaDiff] = Field(None, description="Différences d'agenda")
+ messages: List[Message] = Field(default_factory=list, description="Messages importants")
+ school_events: List[SchoolEvent] = Field(default_factory=list, description="Événements scolaires")
+ target_date: date = Field(..., description="Date cible")
+
+
+class SynthesisResult(BaseModel):
+ """
+ Résultat de la synthèse IA.
+ """
+ text: Optional[str] = Field(None, description="Texte de la synthèse IA")
+
+
+
+
+### 6.3 Points clés
+- **Immuabilité** : Les modèles de **contrat** (ex: `Lesson`, `Homework`, `Message`, `XmppMessage`) utilisent `frozen=True`. Les modèles de **travail** (ex: `CalDAVSyncResult`, `PronoteData`) sont mutables pour permettre les mises à jour progressives.
+- **Sérialisation** : Les champs `datetime` et `date` sont sérialisés en ISO format pour JSON.
+- **Validation** : Pydantic valide automatiquement les types et les contraintes (ex: `date` doit être un objet `date` valide).
+- **Séparation des préoccupations** :
+ - `PronoteData` : Données normalisées de Pronote.
+ - `AgendaDiff` : Résultat de la comparaison avec l'agenda théorique.
+ - `CalDAVSyncPlan`/`CalDAVSyncResult` : Plan et résultat de la synchronisation CalDAV.
+ - `SynthesisInput`/`SynthesisResult` : Entrée et sortie de la synthèse IA.
+ - `XmppMessage` : Message prêt à être envoyé.
+
+---
+
+## 7. Synchronisation CalDAV
+
+### 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.
+- **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.
+
+### 7.2 Client CalDAV (`sync/caldav.py`)
+
+Utilisation de la bibliothèque [`caldav`](https://pypi.org/project/caldav/) (Python 3.8+, maintenue).
+
+```python
+from typing import List, Optional, Dict, Any
+from datetime import datetime, timedelta
+import caldav
+from caldav.elements import DAVCalendar, DAVEvent
+from ..models.agenda import Lesson, Homework, SchoolEvent
+from ..models.sync import CalDAVSyncResult, CalDAVSyncStatus
+from ..utils.uid import normalize_pronote_uid
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class CalDAVClient:
+ """
+ Client pour interagir avec un serveur CalDAV.
+ Gère la synchronisation différentielle des événements Pronote.
+ """
+
+ # Marqueur pour identifier les événements gérés par l'outil
+ MANAGED_PROPERTY = "X-PRONOTE-SYNC-MANAGED"
+ MANAGED_VALUE = "v1"
+
+ def __init__(
+ self,
+ url: str,
+ username: str,
+ password: str,
+ calendar_name: str = "Pronote",
+ dry_run: bool = False,
+ ):
+ self.url = url
+ self.username = username
+ self.password = password
+ self.calendar_name = calendar_name
+ self.dry_run = dry_run
+ self._client: Optional[caldav.DAVClient] = None
+ self._calendar: Optional[DAVCalendar] = None
+
+ def connect(self) -> None:
+ """Établit la connexion au serveur CalDAV."""
+ self._client = caldav.DAVClient(
+ url=self.url,
+ username=self.username,
+ 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
+
+ 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
+
+ def _get_event_uid(self, event: DAVEvent) -> str:
+ """Récupère l'UID normalisé d'un événement."""
+ uid = event.vobject_instance.uid.value
+ 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
+
+ event = Event()
+ event.add("uid", vUri(lesson.id))
+ event.add("summary", vText(lesson.subject))
+ event.add("dtstart", vDatetime(lesson.start))
+ event.add("dtend", vDatetime(lesson.end))
+
+ # Ajouter les professeurs et salles dans la description
+ teachers = ", ".join(lesson.teachers) if lesson.teachers else ""
+ rooms = ", ".join(lesson.rooms) if lesson.rooms else ""
+ description = f"Matière : {lesson.subject}\n"
+ if teachers:
+ description += f"Professeur(s) : {teachers}\n"
+ if rooms:
+ description += f"Salle(s) : {rooms}\n"
+ if lesson.content:
+ description += f"\nContenu : {lesson.content}"
+ event.add("description", vText(description))
+
+ # Statut
+ if lesson.status == LessonStatus.CANCELLED:
+ event.add("status", "CANCELLED")
+ elif lesson.status == LessonStatus.MOVED:
+ event.add("status", "CONFIRMED") # ou un statut personnalisé
+ else:
+ event.add("status", "CONFIRMED")
+
+ # Marqueur pour identifier les événements gérés
+ event.add(self.MANAGED_PROPERTY, self.MANAGED_VALUE)
+
+ # Catégories
+ categories = ["Pronote"]
+ if lesson.status == LessonStatus.CANCELLED:
+ categories.append("Annulé")
+ elif lesson.status == LessonStatus.MOVED:
+ 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:
+ """Construit un événement CalDAV à partir d'un devoir."""
+ from icalendar import Event, vDatetime, vDate, vText, vUri
+
+ # Utiliser la date d'échéance comme date de début/fin
+ due_date = homework.due_on
+ start = datetime(due_date.year, due_date.month, due_date.day, 8, 0, 0)
+ end = datetime(due_date.year, due_date.month, due_date.day, 18, 0, 0)
+
+ event = Event()
+ event.add("uid", vUri(f"homework-{homework.id}"))
+ event.add("summary", vText(f"Devoir : {homework.subject}"))
+ event.add("dtstart", vDatetime(start))
+ event.add("dtend", vDatetime(end))
+
+ # Description
+ description = f"Matière : {homework.subject}\n"
+ description += f"À faire pour le : {due_date.strftime('%d/%m/%Y')}\n"
+ description += f"\n{homework.text}"
+ event.add("description", vText(description))
+
+ # Statut : Tâche (TODO)
+ event.add("status", "NEEDS-ACTION")
+
+ # Marqueur
+ event.add(self.MANAGED_PROPERTY, self.MANAGED_VALUE)
+ event.add("categories", ["Pronote", "Devoir"])
+
+ return DAVEvent(event)
+
+ def _build_school_event_event(self, school_event: SchoolEvent) -> DAVEvent:
+ """Construit un événement CalDAV à partir d'un événement scolaire."""
+ from icalendar import Event, vDate, vText, vUri
+
+ event = Event()
+ event.add("uid", vUri(f"school-event-{school_event.label}-{school_event.from_date.isoformat()}"))
+ event.add("summary", vText(school_event.label))
+ event.add("dtstart", vDate(school_event.from_date))
+ event.add("dtend", vDate(school_event.to_date))
+
+ # Statut
+ event.add("status", "CONFIRMED")
+
+ # Marqueur
+ event.add(self.MANAGED_PROPERTY, self.MANAGED_VALUE)
+ event.add("categories", ["Pronote", school_event.kind.value])
+
+ return DAVEvent(event)
+
+ def _events_equal(self, event1: DAVEvent, event2: DAVEvent) -> bool:
+ """
+ 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.
+
+ Args:
+ event1: Événement existant dans CalDAV.
+ event2: Nouvel événement à synchroniser.
+
+ Returns:
+ 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:
+ return False
+
+ # Comparaison des champs gérés
+ vobj1 = event1.vobject_instance
+ vobj2 = event2.vobject_instance
+
+ # DTSTART et DTEND
+ if vobj1.get("dtstart").value != vobj2.get("dtstart").value:
+ return False
+ if vobj1.get("dtend").value != vobj2.get("dtend").value:
+ return False
+
+ # SUMMARY
+ if str(vobj1.get("summary")) != str(vobj2.get("summary")):
+ return False
+
+ # DESCRIPTION
+ if str(vobj1.get("description")) != str(vobj2.get("description")):
+ return False
+
+ # STATUS
+ if str(vobj1.get("status")) != str(vobj2.get("status")):
+ 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 []
+ if sorted(cats1) != sorted(cats2):
+ return False
+
+ # X-PRONOTE-SYNC-MANAGED (doit toujours être présent et égal)
+ if str(vobj1.get(self.MANAGED_PROPERTY)) != str(vobj2.get(self.MANAGED_PROPERTY)):
+ return False
+
+ return True
+
+ def sync(
+ self,
+ lessons: List[Lesson],
+ homeworks: List[Homework],
+ school_events: List[SchoolEvent],
+ past_days: int = 7,
+ future_days: int = 30,
+ ) -> CalDAVSyncResult:
+ """
+ Synchronise les événements Pronote vers CalDAV.
+ **Idempotent** : Deux exécutions identiques sans changement externe ne modifient pas le calendrier.
+
+ Args:
+ lessons: Liste des cours à synchroniser.
+ homeworks: Liste des devoirs à synchroniser.
+ school_events: Liste des événements scolaires à synchroniser.
+ past_days: Nombre de jours dans le passé à synchroniser.
+ future_days: Nombre de jours dans le futur à synchroniser.
+
+ Returns:
+ Résultat de la synchronisation.
+ """
+ if self._calendar is None:
+ return CalDAVSyncResult(
+ status=CalDAVSyncStatus.SKIPPED,
+ errors=["Aucun calendrier disponible (dry_run ou erreur de connexion)"],
+ )
+
+ result = CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS)
+
+ # Calculer la plage de dates
+ today = datetime.now().date()
+ start_date = today - timedelta(days=past_days)
+ end_date = today + timedelta(days=future_days)
+
+ # Récupérer les événements existants dans la plage
+ try:
+ existing_events = list(
+ self._calendar.date_search(
+ start=start_date,
+ end=end_date,
+ expand=True,
+ )
+ )
+ except Exception as e:
+ safe_error = redact_secrets(str(e))
+ logger.error(f"Échec de la récupération des événements CalDAV: {safe_error}")
+ return CalDAVSyncResult(
+ status=CalDAVSyncStatus.FAILED,
+ errors=[f"Échec de la récupération des événements: {safe_error}"],
+ )
+
+ # Indexer les événements existants par UID normalisé
+ existing_by_uid: Dict[str, DAVEvent] = {}
+ for event in existing_events:
+ if self._is_managed_event(event):
+ uid = self._get_event_uid(event)
+ existing_by_uid[uid] = event
+
+ # **Tests d'idempotence** : Deux exécutions consécutives avec les mêmes données
+ # ne doivent effectuer **aucune écriture** (result.added = 0, result.updated = 0, result.removed = 0).
+ # Voir les tests dans `tests/integration/test_caldav.py` (ex: `test_sync_idempotent`).
+
+ # Synchroniser les cours
+ for lesson in lessons:
+ if not (start_date <= lesson.start.date() <= end_date):
+ continue
+
+ uid = lesson.id
+ if uid in existing_by_uid:
+ # Comparer l'événement existant avec le nouvel événement
+ existing_event = existing_by_uid[uid]
+ new_event = self._build_event(lesson)
+
+ # Ne mettre à jour que si les événements diffèrent
+ if not self._events_equal(existing_event, new_event):
+ if not self.dry_run:
+ try:
+ existing_event.vobject_instance = new_event.vobject_instance
+ existing_event.save()
+ result.updated += 1
+ except Exception as e:
+ safe_error = redact_secrets(str(e))
+ logger.error(f"Échec de la mise à jour de {uid}: {safe_error}")
+ result.errors.append(f"Mise à jour {uid}: {safe_error}")
+ else:
+ result.updated += 1
+ logger.info(f"[DRY-RUN] Mise à jour de {uid}")
+ # Sinon, aucun changement : ne pas compter comme mise à jour
+ else:
+ # Ajouter un nouvel événement
+ new_event = self._build_event(lesson)
+
+ if not self.dry_run:
+ try:
+ self._calendar.add_event(new_event)
+ result.added += 1
+ except Exception as e:
+ safe_error = redact_secrets(str(e))
+ logger.error(f"Échec de l'ajout de {uid}: {safe_error}")
+ result.errors.append(f"Ajout {uid}: {safe_error}")
+ else:
+ result.added += 1
+ logger.info(f"[DRY-RUN] Ajout de {uid}")
+
+ # Synchroniser les devoirs
+ for homework in homeworks:
+ if not (start_date <= homework.due_on <= end_date):
+ continue
+
+ uid = f"homework-{homework.id}"
+ if uid in existing_by_uid:
+ # Comparer l'événement existant avec le nouvel événement
+ existing_event = existing_by_uid[uid]
+ new_event = self._build_homework_event(homework)
+
+ # Ne mettre à jour que si les événements diffèrent
+ if not self._events_equal(existing_event, new_event):
+ if not self.dry_run:
+ try:
+ existing_event.vobject_instance = new_event.vobject_instance
+ existing_event.save()
+ result.updated += 1
+ except Exception as e:
+ safe_error = redact_secrets(str(e))
+ logger.error(f"Échec de la mise à jour du devoir {uid}: {safe_error}")
+ result.errors.append(f"Mise à jour devoir {uid}: {safe_error}")
+ else:
+ result.updated += 1
+ logger.info(f"[DRY-RUN] Mise à jour du devoir {uid}")
+ else:
+ # Ajouter
+ new_event = self._build_homework_event(homework)
+
+ if not self.dry_run:
+ try:
+ self._calendar.add_event(new_event)
+ result.added += 1
+ except Exception as e:
+ safe_error = redact_secrets(str(e))
+ logger.error(f"Échec de l'ajout du devoir {uid}: {safe_error}")
+ result.errors.append(f"Ajout devoir {uid}: {safe_error}")
+ else:
+ result.added += 1
+ logger.info(f"[DRY-RUN] Ajout du devoir {uid}")
+
+ # Synchroniser les événements scolaires
+ for school_event in school_events:
+ if not (school_event.from_date >= start_date and school_event.to_date <= end_date):
+ continue
+
+ uid = f"school-event-{school_event.label}-{school_event.from_date.isoformat()}"
+ if uid in existing_by_uid:
+ # Comparer l'événement existant avec le nouvel événement
+ existing_event = existing_by_uid[uid]
+ new_event = self._build_school_event_event(school_event)
+
+ # Ne mettre à jour que si les événements diffèrent
+ if not self._events_equal(existing_event, new_event):
+ if not self.dry_run:
+ try:
+ existing_event.vobject_instance = new_event.vobject_instance
+ existing_event.save()
+ result.updated += 1
+ except Exception as e:
+ safe_error = redact_secrets(str(e))
+ logger.error(f"Échec de la mise à jour de l'événement {uid}: {safe_error}")
+ result.errors.append(f"Mise à jour événement {uid}: {safe_error}")
+ else:
+ result.updated += 1
+ logger.info(f"[DRY-RUN] Mise à jour de l'événement {uid}")
+ else:
+ # Ajouter
+ new_event = self._build_school_event_event(school_event)
+
+ if not self.dry_run:
+ try:
+ self._calendar.add_event(new_event)
+ result.added += 1
+ except Exception as e:
+ safe_error = redact_secrets(str(e))
+ logger.error(f"Échec de l'ajout de l'événement {uid}: {safe_error}")
+ result.errors.append(f"Ajout événement {uid}: {safe_error}")
+ else:
+ result.added += 1
+ logger.info(f"[DRY-RUN] Ajout de l'événement {uid}")
+
+ # Supprimer les événements gérés qui n'existent plus
+ # **À implémenter avec prudence** :
+ # - Ne supprimer que les événements marqués comme gérés.
+ # - Vérifier qu'ils ne sont plus dans les listes lessons/homeworks/school_events.
+ # Exemple :
+ current_uids = {
+ lesson.id for lesson in lessons
+ } | {
+ f"homework-{hw.id}" for hw in homeworks
+ } | {
+ f"school-event-{se.label}-{se.from_date.isoformat()}" for se in school_events
+ }
+
+ for uid, event in existing_by_uid.items():
+ if uid not in current_uids:
+ if not self.dry_run:
+ try:
+ event.delete()
+ result.removed += 1
+ except Exception as e:
+ safe_error = redact_secrets(str(e))
+ logger.error(f"Échec de la suppression de {uid}: {safe_error}")
+ result.errors.append(f"Suppression {uid}: {safe_error}")
+ else:
+ result.removed += 1
+ logger.info(f"[DRY-RUN] Suppression de {uid}")
+
+ # Définir le statut final
+ if result.errors:
+ result.status = CalDAVSyncStatus.FAILED
+ elif result.added == 0 and result.updated == 0 and result.removed == 0:
+ result.status = CalDAVSyncStatus.SKIPPED
+
+ return result
+
+ def close(self) -> None:
+ """Fermeture de la connexion."""
+ self._client = None
+ self._calendar = None
+```
+
+### 7.3 État de synchronisation (`sync/state.py`)
+
+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.
+
+**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.
+
+#### 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()
+```
+
+### 7.4 Points clés
+- **Différentielle** : La synchronisation compare les UID existants avec ceux à synchroniser.
+- **Idempotence** : Deux exécutions identiques ne modifient pas le calendrier.
+- **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).
+
+---
+
+## 8. Comparaison avec l'agenda théorique
+
+### 8.1 Principes
+- **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.
+- **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`)
+
+```python
+from typing import Protocol, List, Optional
+from datetime import date, time
+from ..models.agenda import TheoreticalLesson
+
+
+class TheoreticalAgendaProvider(Protocol):
+ """
+ Protocole pour les fournisseurs d'agenda théorique.
+ Permet de changer facilement la source (fichier, API, etc.).
+ """
+
+ def get_lessons(self, date: date) -> List[TheoreticalLesson]:
+ """
+ Récupère les cours théoriques pour une date donnée.
+
+ Args:
+ date: Date pour laquelle récupérer les cours.
+
+ Returns:
+ Liste des cours théoriques.
+ """
+ ...
+
+ def get_lessons_for_range(
+ self,
+ start_date: date,
+ end_date: date,
+ ) -> List[TheoreticalLesson]:
+ """
+ Récupère les cours théoriques pour une plage de dates.
+
+ Args:
+ start_date: Date de début (inclusive).
+ end_date: Date de fin (inclusive).
+
+ Returns:
+ Liste des cours théoriques.
+ """
+ ...
+```
+
+
+### 8.3 Implémentation par fichier (`sources/theoretical/file.py`)
+
+#### 8.3.1 Fichier iCal
+
+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.
+
+```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,
+ }
+ 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
+```
+
+
+### 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}`).
+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** :
+```python
+# Tri des cours théoriques par ID stable (pour un matching déterministe)
+theoretical_lessons_sorted = sorted(
+ theoretical_lessons,
+ key=lambda lesson: (
+ lesson.day_of_week,
+ lesson.start_time,
+ lesson.end_time,
+ lesson.subject.lower(),
+ ),
+)
+
+**Exemple de matching avec départage déterministe** :
+```python
+def match_theoretical_event(
+ real_lesson: PronoteLesson,
+ theoretical_events: list[TheoreticalEvent],
+ tolerance_minutes: int = 15,
+) -> TheoreticalEvent | None:
+ """Trouve l'événement théorique correspondant, avec départage déterministe."""
+ candidates = [
+ t for t in theoretical_events
+ if abs((t.start - real_lesson.start).total_seconds()) <= tolerance_minutes * 60
+ 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))
+ return candidates[0]
+```
+
+---
+
+### 8.5 Logique de comparaison (`sync/diff.py`)
+
+```python
+from typing import List, Tuple, Optional
+from datetime import date, time, timedelta
+from ..models.agenda import Lesson, TheoreticalLesson
+from ..models.diff import AgendaDiff, AgendaChange, AgendaChangeType
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class AgendaComparator:
+ """
+ Compare l'agenda réel (Pronote) avec l'agenda théorique.
+ """
+
+ # Tolérance pour le matching des heures (en minutes)
+ TIME_TOLERANCE = 5
+
+ def __init__(self, theoretical_provider: TheoreticalAgendaProvider):
+ self.theoretical_provider = theoretical_provider
+
+ def _normalize_subject(self, subject: str) -> str:
+ """Normalise le nom d'une matière pour le matching."""
+ import re
+ # Supprimer les accents, passer en minuscules, supprimer les espaces multiples
+ subject = re.sub(r"[^\w\s]", "", subject) # Supprimer la ponctuation
+ subject = re.sub(r"\s+", " ", subject).strip().lower()
+ return subject
+
+ def _normalize_time(self, t: time) -> time:
+ """Normalise une heure (arrondir à 5 minutes près)."""
+ minute = (t.minute // 5) * 5
+ return time(t.hour, minute)
+
+ def _match_lesson(
+ self,
+ real_lesson: Lesson,
+ theoretical_lessons: List[TheoreticalLesson],
+ ) -> Optional[TheoreticalLesson]:
+ """
+ Trouve le cours théorique correspondant à un cours réel.
+
+ Args:
+ real_lesson: Cours réel (Pronote).
+ theoretical_lessons: Liste des cours théoriques pour le même jour.
+
+ Returns:
+ Cours théorique correspondant ou None.
+
+ **Politique de départage** :
+ Si plusieurs cours théoriques correspondent, on trie par UID stable (pour un matching déterministe)
+ et on retourne le premier.
+ """
+ real_day = real_lesson.start.weekday()
+ real_start = self._normalize_time(real_lesson.start.time())
+ real_end = self._normalize_time(real_lesson.end.time())
+ real_subject = self._normalize_subject(real_lesson.subject)
+
+ # Collecter tous les candidats correspondants
+ candidates = []
+ for theoretical in theoretical_lessons:
+ if theoretical.day_of_week != real_day:
+ continue
+
+ theo_start = self._normalize_time(theoretical.start_time)
+ theo_end = self._normalize_time(theoretical.end_time)
+ theo_subject = self._normalize_subject(theoretical.subject)
+
+ # Matching sur :
+ # 1. Créneau horaire (avec tolérance)
+ # 2. Matière normalisée
+ if (
+ theo_start == real_start
+ and theo_end == real_end
+ and theo_subject == real_subject
+ ):
+ candidates.append(theoretical)
+
+ # Trier les candidats par UID stable pour un matching déterministe
+ candidates.sort(key=lambda t: t.id)
+
+ return candidates[0] if candidates else None
+
+ def compare_for_date(self, date: date, real_lessons: List[Lesson]) -> AgendaDiff:
+ """
+ Compare l'agenda réel et théorique pour une date donnée.
+
+ Args:
+ date: Date à comparer.
+ real_lessons: Liste des cours réels pour cette date.
+
+ Returns:
+ Différences entre les deux agendas.
+ """
+ theoretical_lessons = self.theoretical_provider.get_lessons(date)
+ changes: List[AgendaChange] = []
+
+ # Indexer les cours réels par ID pour éviter les doublons
+ real_by_id = {lesson.id: lesson for lesson in real_lessons}
+
+ # 1. Trouver les cours ajoutés ou modifiés
+ for real_lesson in real_lessons:
+ matched = self._match_lesson(real_lesson, theoretical_lessons)
+
+ if matched is None:
+ # Cours ajouté (pas dans l'agenda théorique)
+ changes.append(AgendaChange(
+ type=AgendaChangeType.ADDED,
+ lesson=real_lesson,
+ theoretical_lesson=None,
+ details="Cours ajouté par rapport à l'agenda théorique",
+ ))
+ else:
+ # Vérifier si le cours a été modifié
+ if (
+ real_lesson.subject != matched.subject
+ or real_lesson.teachers != matched.teachers
+ or real_lesson.rooms != matched.rooms
+ or real_lesson.status != LessonStatus.NORMAL
+ ):
+ changes.append(AgendaChange(
+ type=AgendaChangeType.MODIFIED,
+ lesson=real_lesson,
+ theoretical_lesson=matched,
+ details=self._describe_changes(real_lesson, matched),
+ ))
+
+ # 2. Trouver les cours supprimés
+ for theoretical in theoretical_lessons:
+ # Vérifier si ce cours théorique a un correspondant réel
+ has_match = any(
+ self._match_lesson(real, [theoretical]) is not None
+ for real in real_lessons
+ )
+
+ if not has_match:
+ changes.append(AgendaChange(
+ type=AgendaChangeType.REMOVED,
+ lesson=None,
+ theoretical_lesson=theoretical,
+ details="Cours supprimé par rapport à l'agenda théorique",
+ ))
+
+ return AgendaDiff(target_date=date, changes=changes)
+
+ def _describe_changes(
+ self,
+ real: Lesson,
+ theoretical: TheoreticalLesson,
+ ) -> str:
+ """Décrit les différences entre un cours réel et un cours théorique."""
+ differences = []
+
+ if real.subject != theoretical.subject:
+ differences.append(f"matière: {theoretical.subject} → {real.subject}")
+
+ if set(real.teachers) != set(theoretical.teachers):
+ differences.append(
+ f"professeurs: {theoretical.teachers} → {real.teachers}"
+ )
+
+ if set(real.rooms) != set(theoretical.rooms):
+ differences.append(f"salles: {theoretical.rooms} → {real.rooms}")
+
+ if real.status != LessonStatus.NORMAL:
+ differences.append(f"statut: {real.status.value}")
+
+ return "; ".join(differences)
+
+ def compare_for_range(
+ self,
+ start_date: date,
+ end_date: date,
+ real_lessons_by_date: dict[date, List[Lesson]],
+ ) -> List[AgendaDiff]:
+ """
+ Compare les agendas pour une plage de dates.
+
+ Args:
+ start_date: Date de début.
+ end_date: Date de fin.
+ real_lessons_by_date: Dictionnaire {date: liste des cours réels}.
+
+ Returns:
+ Liste des différences par date.
+ """
+ diffs = []
+ current_date = start_date
+ while current_date <= end_date:
+ real_lessons = real_lessons_by_date.get(current_date, [])
+ diff = self.compare_for_date(current_date, real_lessons)
+ if diff.changes:
+ diffs.append(diff)
+ current_date += timedelta(days=1)
+ return diffs
+```
+
+
+### 8.7 Points clés
+- **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.
+
+---
+
+## 9. Synthèse IA
+
+### 9.1 Principes
+- **Optionnelle** : La synthèse IA ne doit **jamais bloquer** le pipeline (décision [5](#5-synthèse-ia---protocole-pas-de-sdk-imposé)).
+- **Périmètre limité** :
+ - **Inclus** : Changements d'agenda, messages importants, informations.
+ - **Exclus** : La liste brute des devoirs (doit rester **intacte**).
+- **Contraintes** :
+ - 3-5 phrases maximum.
+ - Ton **chaleureux et sobre**.
+ - **Pas d'emoji dans le texte généré par l'IA**, pas de titre, pas de liste.
+ *Note* : Les emojis sont autorisés dans le **formatage du message XMPP** (ex: 📌, 📅, 📚, 💬) pour améliorer la lisibilité.
+ - **Ne pas inventer** d'informations.
+ - **Rejeter** les horaires non connus (ex: "à 14h" si l'heure n'est pas dans les données).
+- **Timeout** : 30 secondes maximum.
+- **Longueur maximale** : 800 caractères.
+
+### 9.2 Protocole `SynthesisProvider` (`synthesis/provider.py`)
+
+```python
+from typing import Protocol, Optional
+from ..models.synthesis import SynthesisInput, SynthesisResult
+
+
+class SynthesisProvider(Protocol):
+ """
+ Protocole pour les fournisseurs de synthèse IA.
+ Permet de changer facilement de fournisseur (OpenAI, Mistral, etc.).
+ """
+
+ def generate(self, input_data: SynthesisInput) -> Optional[SynthesisResult]:
+ """
+ Génère une synthèse IA à partir des données Pronote.
+
+ Args:
+ input_data: Données Pronote (`PronoteData`) à synthétiser.
+
+ Returns:
+ Synthèse IA (string) ou None en cas d'échec.
+ **Ne doit jamais lever d'exception** (retourner None à la place).
+ """
+ ...
+```
+
+
+### 9.3 Adaptateur OpenAI (`synthesis/openai.py`)
+
+```python
+from typing import Optional
+import httpx
+from ..models.synthesis import SynthesisInput, SynthesisResult
+from .provider import SynthesisProvider
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class OpenAISynthesisProvider:
+ """
+ Fournisseur de synthèse IA utilisant l'API OpenAI.
+ Compatible avec les API OpenAI-compatibles (ex: Mistral, Google via litellm).
+ """
+
+ # Prompt système en français (inspiré de src/intro/prompt.ts)
+ SYSTEM_PROMPT = """
+Tu es un assistant bienveillant qui résume les informations importantes pour un parent.
+Rédige une synthèse en **3 à 5 phrases maximum**, dans un **ton chaleureux et sobre**.
+
+Règles strictes :
+- N'utilise **aucun emoji**, aucun titre, aucune liste.
+- Ne mentionne **aucun horaire** (ex: "à 14h") sauf si l'heure est explicitement dans les données.
+- **N'invente rien** : ne mentionne que ce qui est présent dans les données.
+- Sois concis et direct.
+- Si aucune information importante n'est disponible, retourne une chaîne vide.
+
+Exemple de format attendu :
+"Le cours de mathématiques de Jean a été annulé demain. Un devoir de français est à rendre pour vendredi. Le professeur a envoyé un message concernant la sortie pédagogique."
+"""
+
+ # Longueur maximale autorisée
+ MAX_LENGTH = 800
+
+ # Timeout en secondes
+ TIMEOUT = 30
+
+ def __init__(
+ self,
+ base_url: Optional[str] = None,
+ api_key: Optional[str] = None,
+ model: str = "gpt-4o-mini",
+ ):
+ self.base_url = base_url.rstrip("/") if base_url else "https://api.openai.com/v1"
+ self.api_key = api_key or ""
+ self.model = model
+
+ def _build_prompt(self, input_data: SynthesisInput) -> str:
+ """Construit le prompt utilisateur à partir des données d'entrée."""
+ parts = []
+
+ # Changements d'agenda
+ if input_data.agenda_diff and input_data.agenda_diff.changes:
+ changes = []
+ for change in input_data.agenda_diff.changes:
+ if change.type == "added":
+ changes.append(f"Cours ajouté : {change.lesson.subject} le {input_data.target_date.strftime('%d/%m/%Y')}")
+ elif change.type == "removed":
+ changes.append(f"Cours supprimé : {change.theoretical_lesson.subject}")
+ elif change.type == "modified":
+ changes.append(f"Cours modifié : {change.lesson.subject} ({change.details})")
+ if changes:
+ parts.append("Changements d'agenda : " + "; ".join(changes))
+
+ # Messages importants
+ if input_data.messages:
+ messages = []
+ for msg in input_data.messages:
+ if not msg.read: # Seuls les messages non lus sont importants
+ messages.append(f"Message de {msg.author} : {msg.title}")
+ if messages:
+ parts.append("Messages : " + "; ".join(messages))
+
+ # Événements scolaires
+ if input_data.school_events:
+ events = []
+ for event in input_data.school_events:
+ events.append(f"{event.label} du {event.from_date.strftime('%d/%m')}")
+ if events:
+ parts.append("Événements : " + "; ".join(events))
+
+ if not parts:
+ return "Aucune information importante à signaler."
+
+ return "\n".join(parts)
+
+ def generate(self, input_data: SynthesisInput) -> Optional[SynthesisResult]:
+ """Génère une synthèse IA."""
+ if not self.api_key:
+ logger.warning("Clé API non configurée. Synthèse IA désactivée.")
+ return None
+
+ try:
+ user_prompt = self._build_prompt(input_data)
+
+ # Appel à l'API OpenAI
+ payload = {
+ "model": self.model,
+ "messages": [
+ {"role": "system", "content": self.SYSTEM_PROMPT},
+ {"role": "user", "content": user_prompt},
+ ],
+ "max_tokens": self.MAX_LENGTH,
+ "temperature": 0.3, # Ton sobre et déterministe
+ }
+
+ headers = {
+ "Authorization": f"Bearer {self.api_key}",
+ "Content-Type": "application/json",
+ }
+
+ with httpx.Client(timeout=self.TIMEOUT) as client:
+ response = client.post(
+ f"{self.base_url}/chat/completions",
+ json=payload,
+ headers=headers,
+ )
+ response.raise_for_status()
+
+ result = response.json()
+ synthesis_text = result["choices"][0]["message"]["content"].strip()
+
+ # Vérifier la longueur
+ if len(synthesis_text) > self.MAX_LENGTH:
+ synthesis_text = synthesis_text[:self.MAX_LENGTH]
+
+ # Nettoyer les éventuels artefacts
+ synthesis_text = synthesis_text.replace("\n", " ").strip()
+
+ return SynthesisResult(text=synthesis_text) if synthesis_text else None
+
+ except Exception as e:
+ safe_error = redact_secrets(str(e))
+ logger.warning(f"Échec de la génération de la synthèse IA: {safe_error}")
+ return None
+```
+
+
+### 9.4 Adaptateur `litellm` (optionnel) (`synthesis/litellm.py`)
+
+`litellm` permet d'utiliser **plusieurs fournisseurs IA** (OpenAI, Mistral, Google, etc.) avec une seule API.
+
+**Installation** : Pour activer le support `litellm`, installer le package optionnel :
+```bash
+pip install .[ai-litellm]
+```
+
+```python
+from typing import Optional
+import litellm
+from ..models.synthesis import SynthesisInput, SynthesisResult
+from .provider import SynthesisProvider
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class LiteLLMSynthesisProvider:
+ """
+ Fournisseur de synthèse IA utilisant litellm.
+ Permet de basculer facilement entre plusieurs modèles.
+ """
+
+ SYSTEM_PROMPT = OpenAISynthesisProvider.SYSTEM_PROMPT
+ MAX_LENGTH = 800
+ TIMEOUT = 30
+
+ def __init__(
+ self,
+ model: str = "gpt-4o-mini",
+ api_key: Optional[str] = None,
+ base_url: Optional[str] = None,
+ ):
+ self.model = model
+ self.api_key = api_key
+ self.base_url = base_url
+
+ # Configuration de litellm (si base_url fourni)
+ if self.base_url:
+ litellm.api_base = self.base_url
+ if self.api_key:
+ litellm.api_key = self.api_key
+
+ def _build_prompt(self, input_data: SynthesisInput) -> str:
+ """Construit le prompt utilisateur."""
+ # Réutiliser la logique de OpenAISynthesisProvider
+ provider = OpenAISynthesisProvider()
+ return provider._build_prompt(input_data)
+
+ def generate(self, input_data: SynthesisInput) -> Optional[SynthesisResult]:
+ """Génère une synthèse IA via litellm."""
+ try:
+ user_prompt = self._build_prompt(input_data)
+
+ response = litellm.completion(
+ model=self.model,
+ messages=[
+ {"role": "system", "content": self.SYSTEM_PROMPT},
+ {"role": "user", "content": user_prompt},
+ ],
+ max_tokens=self.MAX_LENGTH,
+ temperature=0.3,
+ )
+
+ synthesis_text = response.choices[0].message.content.strip()
+
+ if len(synthesis_text) > self.MAX_LENGTH:
+ synthesis_text = synthesis_text[:self.MAX_LENGTH]
+
+ synthesis_text = synthesis_text.replace("\n", " ").strip()
+
+ return SynthesisResult(text=synthesis_text) if synthesis_text else None
+
+ except Exception as e:
+ safe_error = redact_secrets(str(e))
+ logger.warning(f"Échec de la génération de la synthèse IA (litellm): {safe_error}")
+ return None
+```
+
+
+### 9.5 Factory pour les fournisseurs IA (`synthesis/__init__.py`)
+
+```python
+from typing import Optional
+from .provider import SynthesisProvider
+from .openai import OpenAISynthesisProvider
+from .litellm import LiteLLMSynthesisProvider
+from ..config.settings import AISettings
+
+
+def get_synthesis_provider(settings: AISettings, provider: Optional[str] = None) -> Optional[SynthesisProvider]:
+ """
+ Fabrique un fournisseur de synthèse IA selon la configuration.
+
+ Args:
+ settings: Configuration IA.
+ provider: Fournisseur explicite à utiliser (ex: "litellm" ou "openai").
+ Si non spécifié, utilise OpenAI-compatible par défaut.
+
+ Returns:
+ Fournisseur de synthèse IA ou None si désactivé.
+ """
+ if not settings.enabled:
+ return None
+
+ if not settings.api_key:
+ return None
+
+ # Utiliser litellm uniquement si explicitement demandé via AI_PROVIDER=litellm
+ if provider == "litellm" or (provider is None and settings.base_url and "litellm" in settings.base_url.lower()):
+ return LiteLLMSynthesisProvider(
+ model=settings.model,
+ api_key=settings.api_key.get_secret_value(),
+ base_url=settings.base_url,
+ )
+
+ # Par défaut : adaptateur OpenAI-compatible (fonctionne avec OpenAI, Mistral, etc.)
+ return OpenAISynthesisProvider(
+ base_url=settings.base_url or "https://api.openai.com/v1",
+ api_key=settings.api_key.get_secret_value(),
+ model=settings.model,
+ )
+```
+
+
+### 9.6 Points clés
+- **Protocole** : `SynthesisProvider` permet de changer facilement de fournisseur.
+- **Mode dégradé** : Si la synthèse échoue, retourner `None` (le pipeline continue).
+- **Prompt système** : En français, avec des contraintes strictes (pas d'emoji, pas d'invention).
+- **Longueur limitée** : 800 caractères maximum.
+- **Timeout** : 30 secondes pour éviter les blocages.
+- **Pas de secrets** : La clé API est masquée dans les logs.
+
+---
+
+## 10. Envoi XMPP
+
+### 10.1 Décision architecturale : Compte XMPP dédié avec message direct
+
+**Option retenue** : **Compte bot dédié** (`pronote-bot@exemple.org`) envoyant un **message direct** au parent.
+**Pas de PubSub (XEP-0060)**.
+
+#### 10.1.1 Rationale
+
+| **Critère** | **Option A : Compte dédié + message direct** | **Option B : PubSub (XEP-0060)** | **Décision** |
+|--------------------------|-----------------------------------------------|----------------------------------|--------------|
+| **Complexité** | ⭐ Simple (1 compte, 1 destinataire) | ⭐⭐⭐ Complexe (nœud, ACL, abonnements) | **Option A** |
+| **Robustesse** | ⭐⭐⭐ Compatible avec tous les serveurs XMPP | ⭐ Dépend du support PubSub côté serveur | **Option A** |
+| **Historique** | ⭐⭐ Géré par MAM (XEP-0313) côté serveur ou client | ⭐⭐⭐ Historique natif via PubSub | **Option A** |
+| **Évolutivité** | ⭐⭐ Possible (liste de JIDs → MUC → PubSub) | ⭐⭐⭐ Évolutif par conception | **Option A** |
+| **Maintenance** | ⭐⭐⭐ Faible (pas de configuration serveur) | ⭐ Maintenance serveur requise | **Option A** |
+
+**Justification** :
+- Pour un **destinataire unique connu** (ex: `parent@exemple.org`), PubSub ajoute une **complexité inutile** (création de nœud, gestion des ACL, abonnements) sans bénéfice suffisant.
+- Un compte bot dédié est **simple, robuste et compatible** avec tous les serveurs XMPP (Prosody, ejabberd, etc.).
+- L'historique peut être assuré par :
+ - **MAM (XEP-0313)** côté serveur.
+ - Le client XMPP (ex: Gajim, Conversations).
+ - L'**archivage local** du digest (déjà implémenté dans le projet TypeScript).
+
+#### 10.1.2 Évolution future
+
+Si le besoin évolue (ex: **plusieurs destinataires**), les étapes suivantes sont envisagées :
+
+1. **Liste de JIDs** :
+ - Le compte bot envoie le message à **plusieurs destinataires** (ex: `parent1@exemple.org`, `parent2@exemple.org`).
+ - **Condition** : Nombre de destinataires ≤ 10.
+ - **Avantage** : Simple à implémenter (boucle sur les JIDs).
+ - **Inconvénient** : Pas de partage de l'historique entre destinataires.
+
+2. **MUC (Multi-User Chat)** :
+ - Créer une **salle privée** (ex: `pronote-digest@muc.exemple.org`) et y inviter les destinataires.
+ - **Condition** : Nombre de destinataires > 10 ou besoin de partage d'historique.
+ - **Avantage** : Historique partagé, gestion centralisée.
+ - **Inconvénient** : Configuration serveur requise (création de salle, gestion des membres).
+
+3. **PubSub (XEP-0060)** :
+ - Créer un **nœud PubSub** (ex: `pronote-digest@pubsub.exemple.org`) et publier les messages.
+ - **Condition** : Besoin de **diffusion large** (ex: toute une classe) ou intégration avec d'autres outils.
+ - **Avantage** : Découplage total entre producteur et consommateurs.
+ - **Inconvénient** : Complexité accrue (création de nœud, ACL, abonnements).
+
+**Règle** : **Ne pas introduire PubSub** tant que le besoin ne dépasse pas les capacités d'un compte dédié + message direct.
+
+---
+
+### 10.2 Configuration XMPP
+
+#### 10.2.1 Variables d'environnement
+
+| **Variable** | **Description** | **Valeur par défaut** | **Type** | **Obligatoire** |
+|----------------------------|-------------------------------------------------------------------------------|-----------------------|-------------------|-----------------|
+| `XMPP_ENABLED` | Activer l'envoi XMPP. | `False` | `bool` | ❌ Non |
+| `XMPP_JID` | Identifiant du compte bot (ex: `pronote-bot@exemple.org`). | `None` | `str` | ✅ Oui |
+| `XMPP_PASSWORD` | Mot de passe du compte bot. | `None` | `SecretStr` | ✅ Oui |
+| `XMPP_HOST` | Hôte XMPP **explicite** (ex: `exemple.org`). | `None` | `str` | ✅ Oui |
+| `XMPP_PORT` | Port XMPP (5222 pour TLS, 5223 pour SSL). | `5222` | `int` | ❌ Non |
+| `XMPP_TO` | Destinataire unique (ex: `parent@exemple.org`). | `None` | `str` | ✅ Oui |
+| `XMPP_RESOURCE` | Ressource XMPP (ex: `pronote-digest`). | `pronote-digest` | `str` | ❌ Non |
+| `XMPP_USE_TLS` | Utiliser TLS pour la connexion. | `True` | `bool` | ❌ Non |
+| `XMPP_TIMEOUT` | Timeout de connexion (secondes). | `30` | `int` | ❌ Non |
+
+**⚠️ Notes** :
+- **`XMPP_HOST` doit être explicite** : Éviter les ambiguïtés DNS/SRV (ex: `exemple.org` au lieu de `xmpp.exemple.org` si le SRV pointe vers `exemple.org`).
+- **Pas de variables PubSub** : `XMPP_PUBSUB_NODE`, `XMPP_ROOM`, `XMPP_SUBSCRIBERS` **ne doivent pas être introduites** pour l'instant.
+- **Sécurité** : `XMPP_JID`, `XMPP_PASSWORD` et `XMPP_TO` **ne doivent jamais apparaître** dans les logs, erreurs ou fixtures.
+- **Standardisation** : `XMPP_TO` est mappé sur le champ `to` dans le modèle Pydantic.
+
+#### 10.2.2 Exemple de configuration dans `.env`
+
+```ini
+# --- XMPP ---
+XMPP_ENABLED=true
+XMPP_JID=pronote-bot@exemple.org
+XMPP_PASSWORD=secret_password # Masqué via SecretStr
+XMPP_HOST=exemple.org
+XMPP_PORT=5222
+XMPP_TO=parent@exemple.org
+XMPP_RESOURCE=pronote-digest
+XMPP_USE_TLS=true
+XMPP_TIMEOUT=30
+```
+
+#### 10.2.3 Modèle Pydantic pour la configuration XMPP
+
+```python
+from pydantic import SecretStr, Field
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class XmppSettings(BaseSettings):
+ model_config = SettingsConfigDict(env_prefix="XMPP_", env_file=".env", extra="ignore")
+ enabled: bool = Field(False, description="Activer l'envoi XMPP")
+ jid: str = Field(..., description="Identifiant du compte bot (ex: pronote-bot@exemple.org)")
+ password: SecretStr = Field(..., description="Mot de passe du compte bot")
+ host: str = Field(..., description="Hôte XMPP explicite (ex: exemple.org)")
+ port: int = Field(5222, description="Port XMPP (5222 pour TLS)")
+ to: str = Field(..., description="Destinataire unique (ex: parent@exemple.org)")
+ resource: str = Field("pronote-digest", description="Ressource XMPP")
+ use_tls: bool = Field(True, description="Utiliser TLS pour la connexion")
+ timeout: int = Field(30, description="Timeout de connexion (secondes)")
+```
+
+---
+
+### 10.3 Protocole `Channel` (`channels/protocol.py`)
+
+```python
+from typing import Protocol
+from ..models.xmpp import XmppMessage
+
+
+class Channel(Protocol):
+ """
+ Protocole pour les canaux de sortie (XMPP, fichier, etc.).
+ **Synchrone** : Le pipeline appelle `send()` sans await.
+ Inspiré de l'interface `Channel` dans src/channels/ du projet TypeScript.
+ """
+
+ name: str
+
+ def send(self, message: XmppMessage) -> bool:
+ """
+ Envoie un message de manière **synchrone**.
+
+ Args:
+ message: Message à envoyer.
+
+ Returns:
+ True si l'envoi a réussi, False sinon.
+ """
+ ...
+```
+
+---
+
+### 10.3 Client XMPP (`channels/xmpp.py`)
+
+```python
+import asyncio
+from typing import Optional, Awaitable
+import slixmpp
+from slixmpp.exceptions import IqError, IqTimeout
+from ..models.xmpp import XmppMessage
+from .protocol import Channel
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class XmppChannel(Channel):
+ """
+ Canal XMPP pour l'envoi des messages.
+ Utilise slixmpp en mode asynchrone.
+ """
+
+ name = "xmpp"
+
+ def __init__(
+ self,
+ jid: str,
+ password: str,
+ recipient: str,
+ dry_run: bool = False,
+ ):
+ self.jid = jid
+ self.password = password
+ self.recipient = recipient
+ self.dry_run = dry_run
+ self._client: Optional[slixmpp.ClientXMPP] = None
+ self._connected = False
+ self._message_sent = False
+
+ async def connect(self) -> bool:
+ """Établit la connexion XMPP."""
+ if self._connected:
+ return True
+
+ try:
+ # Créer le client
+ self._client = slixmpp.ClientXMPP(self.jid, self.password)
+
+ # Configurer les handlers
+ self._client.add_event_handler("session_start", self._on_session_start)
+ self._client.add_event_handler("failed_auth", self._on_failed_auth)
+ self._client.add_event_handler("disconnected", self._on_disconnected)
+
+ # Se connecter (async)
+ self._client.connect()
+ self._client.process(block=False)
+
+ # Attendre la connexion (timeout: 30s)
+ await asyncio.wait_for(
+ self._wait_for_connection(),
+ timeout=30.0,
+ )
+
+ return self._connected
+
+ except Exception as e:
+ logger.error(f"Échec de la connexion XMPP: {redact_secrets(str(e))}")
+ return False
+
+ def _on_session_start(self, event: slixmpp.Event) -> None:
+ """Handler appelé quand la session XMPP est établie."""
+ self._connected = True
+ logger.info("Connexion XMPP établie")
+
+ def _on_failed_auth(self, event: slixmpp.Event) -> None:
+ """Handler appelé en cas d'échec d'authentification."""
+ logger.error("Échec de l'authentification XMPP")
+ self._connected = False
+
+ def _on_disconnected(self, event: slixmpp.Event) -> None:
+ """Handler appelé en cas de déconnexion."""
+ logger.warning("Déconnexion XMPP")
+ self._connected = False
+
+ async def _wait_for_connection(self) -> None:
+ """Attend que la connexion soit établie."""
+ while not self._connected:
+ await asyncio.sleep(0.1)
+
+ def _format_message(self, message: XmppMessage) -> str:
+ """Formate le message XMPP en texte brut."""
+ lines = []
+
+ # Titre (date cible)
+ lines.append(f"=== Pronote - {message.target_date.strftime('%A %d %B %Y')} ===")
+ lines.append("")
+
+ # Synthèse IA (si disponible)
+ if message.synthesis:
+ lines.append("📌 Synthèse :")
+ lines.append(message.synthesis)
+ lines.append("")
+
+ # Changements d'agenda
+ if message.changes:
+ lines.append("📅 Changements d'agenda :")
+ for change in message.changes:
+ if change.type == "added":
+ lines.append(f" + {change.lesson.subject} ({change.lesson.start.strftime('%H:%M')})")
+ elif change.type == "removed":
+ lines.append(f" - {change.theoretical_lesson.subject}")
+ elif change.type == "modified":
+ lines.append(f" ~ {change.lesson.subject} ({change.details})")
+ lines.append("")
+
+ # Liste brute des devoirs
+ if message.homeworks:
+ lines.append("📚 Devoirs :")
+ for hw in message.homeworks:
+ due_date = hw.due_on.strftime("%d/%m/%Y")
+ lines.append(f" - {hw.subject} (pour le {due_date}) : {hw.text}")
+ lines.append("")
+
+ # Messages
+ if message.messages:
+ lines.append("💬 Messages :")
+ for msg in message.messages:
+ lines.append(f" - {msg.author} : {msg.title}")
+
+ return "\n".join(lines)
+
+ async def send(self, message: XmppMessage) -> bool:
+ """Envoie un message XMPP."""
+ if not self._connected:
+ # Se connecter si ce n'est pas déjà fait
+ if not await self.connect():
+ return False
+
+ if self.dry_run:
+ logger.info(f"[DRY-RUN] Envoi XMPP à {self.recipient}")
+ logger.info(f"Contenu:\n{self._format_message(message)}")
+ return True
+
+ try:
+ # Formater le message
+ body = self._format_message(message)
+
+ # Envoyer le message
+ self._client.send_message(
+ mto=self.recipient,
+ mbody=body,
+ mtype="chat",
+ )
+
+ logger.info(f"Message XMPP envoyé à {self.recipient}")
+ return True
+
+ except Exception as e:
+ safe_error = redact_secrets(str(e))
+ logger.error(f"Échec de l'envoi XMPP: {safe_error}")
+ return False
+
+ async def disconnect(self) -> None:
+ """Déconnecte le client XMPP."""
+ if self._client:
+ self._client.disconnect()
+ self._connected = False
+
+
+class SyncXmppChannel:
+ """
+ Adaptateur synchrone pour XMPP.
+ Encapsule asyncio avec une stratégie robuste pour éviter les conflits de boucle d'événements.
+
+ **Important** : Si le pipeline est appelé depuis un contexte asynchrone, l'envoi XMPP doit être isolé
+ dans un thread séparé pour éviter les conflits de boucle.
+ """
+
+ def __init__(
+ self,
+ jid: str,
+ password: str,
+ recipient: str,
+ dry_run: bool = False,
+ ):
+ self.jid = jid
+ self.password = password
+ self.recipient = recipient
+ self.dry_run = dry_run
+ self._xmpp_channel = XmppChannel(jid, password, recipient, dry_run)
+
+ def send(self, message: XmppMessage) -> bool:
+ """Envoie un message XMPP de manière synchrone."""
+ import asyncio
+
+ # Créer une nouvelle boucle d'événements pour éviter les conflits
+ loop = asyncio.new_event_loop()
+ try:
+ asyncio.set_event_loop(loop)
+ return loop.run_until_complete(self._xmpp_channel.send(message))
+ finally:
+ loop.close()
+ asyncio.set_event_loop(None)
+```
+
+
+### 10.4 Factory pour les canaux (`channels/__init__.py`)
+
+```python
+from typing import List, Dict, Type
+from .protocol import Channel
+from .xmpp import SyncXmppChannel
+from ..config.settings import Settings
+
+
+# Registre des factories de canaux
+_CHANNEL_FACTORIES: Dict[str, Type[Channel]] = {
+ "xmpp": SyncXmppChannel,
+}
+
+
+def get_channel(settings: Settings, channel_name: str = "xmpp") -> Channel:
+ """
+ Fabrique un canal selon la configuration.
+
+ Args:
+ settings: Configuration globale.
+ channel_name: Nom du canal (défaut: "xmpp").
+
+ Returns:
+ Canal configuré.
+ """
+ factory = _CHANNEL_FACTORIES.get(channel_name)
+ if factory is None:
+ raise ValueError(f"Canal inconnu: {channel_name}")
+
+ if channel_name == "xmpp":
+ if not settings.xmpp.enabled:
+ raise ValueError("XMPP est désactivé (XMPP_ENABLED=False)")
+ return factory(
+ jid=settings.xmpp.jid,
+ password=settings.xmpp.password.get_secret_value(),
+ recipient=settings.xmpp.to,
+ dry_run=settings.app.dry_run,
+ )
+
+ raise ValueError(f"Canal {channel_name} non implémenté")
+```
+
+
+### 10.5 Points clés
+- **slixmpp** : Bibliothèque recommandée pour XMPP (asyncio, maintenue).
+- **Format du message** : Structuré avec sections claires (synthèse, changements, devoirs, messages).
+- **Mode dégradé** : Si XMPP échoue, le pipeline peut continuer (mais le message ne sera pas envoyé).
+- **Dry-run** : Mode obligatoire pour tester sans envoyer de message.
+- **Reconnexion** : Gestion des erreurs de connexion.
+
+---
+
+## 11. Gestion des erreurs et modes dégradés
+
+### 11.1 Principes
+- **Ne jamais bloquer le pipeline** : Une erreur dans une étape ne doit pas empêcher les autres étapes de s'exécuter (sauf si critique).
+- **Modes dégradés** :
+ - **Synthèse IA** : Si elle échoue → envoyer le message **sans synthèse** (mais avec la liste brute des devoirs).
+ - **pronotepy** : Si la récupération échoue → basculer sur **iCal** (si disponible).
+ - **iCal** : Si la récupération échoue → basculer sur **pronotepy** (si configuré).
+ - **CalDAV** : Si la synchronisation échoue → **logger l'erreur** mais continuer le pipeline.
+ - **XMPP** : Si l'envoi échoue → **logger l'erreur** mais continuer le pipeline.
+- **Erreurs critiques** :
+ - **Aucune source disponible** (iCal + pronotepy échouent) → **échec explicite** avec message clair.
+ - **Configuration invalide** (ex: `PRONOTE_ICAL_URL` manquant) → **échec explicite**.
+
+### 11.2 Hiérarchie des erreurs
+
+```python
+from enum import Enum, auto
+from typing import Optional
+
+
+class ErrorSeverity(Enum):
+ """Niveau de gravité d'une erreur."""
+ DEBUG = auto() # Erreur mineure (ex: warning de parsing)
+ WARNING = auto() # Erreur non bloquante (ex: synthèse IA échouée)
+ ERROR = auto() # Erreur bloquante pour une étape (ex: récupération Pronote échouée)
+ CRITICAL = auto() # Erreur bloquante pour le pipeline (ex: aucune source disponible)
+
+
+class PipelineError(Exception):
+ """Erreur dans le pipeline."""
+
+ def __init__(
+ self,
+ message: str,
+ severity: ErrorSeverity = ErrorSeverity.ERROR,
+ step: Optional[str] = None,
+ recoverable: bool = False,
+ ):
+ super().__init__(message)
+ self.message = message
+ self.severity = severity
+ self.step = step
+ self.recoverable = recoverable
+
+
+class PipelineWarning(PipelineError):
+ """Avertissement dans le pipeline (non bloquant)."""
+
+ def __init__(self, message: str, step: Optional[str] = None):
+ super().__init__(message, ErrorSeverity.WARNING, step, recoverable=True)
+
+
+class PipelineCriticalError(PipelineError):
+ """Erreur critique dans le pipeline (bloquante)."""
+
+ def __init__(self, message: str, step: Optional[str] = None):
+ super().__init__(message, ErrorSeverity.CRITICAL, step, recoverable=False)
+```
+
+
+### 11.3 Gestion des erreurs dans le pipeline (`pipeline/run.py`)
+
+```python
+from typing import List, Optional, Tuple
+from ..models.agenda import Lesson, Homework, SchoolEvent
+from ..models.xmpp import XmppMessage
+from ..models.pronote import PronoteData
+from ..models.sync import CalDAVSyncResult
+from ..models.synthesis import SynthesisInput, SynthesisResult
+from ..sources.pronote.fetcher import PronoteFetcher
+from ..sync.caldav import CalDAVClient
+from ..sync.diff import AgendaComparator
+from ..synthesis.provider import SynthesisProvider
+from ..channels.protocol import Channel
+from .steps import (
+ fetch_step,
+ normalize_step,
+ compare_step,
+ caldav_sync_step,
+ synthesis_step,
+ send_step,
+ fetch_blog_step,
+)
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class PipelineRunner:
+ """
+ Orchestre l'exécution du pipeline avec gestion des erreurs.
+ Ordre des étapes :
+ 1. Récupération Pronote
+ 2. Normalisation
+ 2 bis. Récupération du blog (RSS)
+ 3. Comparaison avec l'agenda théorique
+ 4. Synchronisation CalDAV
+ 5. Synthèse IA (optionnelle)
+ 6. Construction du message XMPP
+ 7. Envoi XMPP
+ """
+
+ def __init__(
+ self,
+ pronote_fetcher: PronoteFetcher,
+ caldav_client: CalDAVClient,
+ agenda_comparator: AgendaComparator,
+ synthesis_provider: Optional[SynthesisProvider],
+ channel: Channel,
+ blog_rss_client: Optional["BlogRSSClient"] = None,
+ blog_state: Optional["BlogRSSState"] = None,
+ dry_run: bool = False,
+ ):
+ self.pronote_fetcher = pronote_fetcher
+ self.caldav_client = caldav_client
+ self.agenda_comparator = agenda_comparator
+ self.synthesis_provider = synthesis_provider
+ self.channel = channel
+ self.blog_rss_client = blog_rss_client
+ self.blog_state = blog_state
+ self.dry_run = dry_run
+ self._errors: List[PipelineError] = []
+ self._warnings: List[PipelineWarning] = []
+
+ def run(self) -> Tuple[Optional[PronoteData], List[PipelineError]]:
+ """
+ Exécute le pipeline complet.
+
+ Returns:
+ Tuple (PronoteData final, liste des erreurs).
+ """
+ pronote_data: Optional[PronoteData] = None
+ agenda_diff = None
+ sync_result: Optional[CalDAVSyncResult] = None
+ synthesis_result: Optional[SynthesisResult] = None
+ blog_articles: List["BlogArticle"] = []
+
+ try:
+ # Étape 1: Récupération Pronote
+ try:
+ lessons, homeworks, school_events, messages = fetch_step(
+ self.pronote_fetcher
+ )
+ except PipelineError as e:
+ if e.severity == ErrorSeverity.CRITICAL:
+ raise
+ self._errors.append(e)
+ logger.warning(f"Étape 'fetch' échouée (non critique): {e.message}")
+ return None, self._errors + self._warnings
+
+ # Étape 2: Normalisation
+ try:
+ pronote_data = normalize_step(lessons, homeworks, school_events, messages)
+ except PipelineError as e:
+ self._errors.append(e)
+ logger.warning(f"Étape 'normalize' échouée: {e.message}")
+ return None, self._errors + self._warnings
+
+ # Étape 2 bis: Récupération du blog (RSS)
+ if self.blog_rss_client and self.blog_state:
+ try:
+ blog_articles = fetch_blog_step(
+ self.blog_rss_client,
+ self.blog_state,
+ enabled=True,
+ )
+ except PipelineError as e:
+ self._warnings.append(PipelineWarning(
+ message=f"Récupération du blog échouée: {e.message}",
+ step="fetch_blog",
+ ))
+ logger.warning(f"Étape 'fetch_blog' échouée (non bloquante): {e.message}")
+ blog_articles = []
+
+ # Étape 3: Comparaison avec l'agenda théorique
+ try:
+ agenda_diff = compare_step(
+ self.agenda_comparator,
+ pronote_data.lessons,
+ pronote_data.target_date,
+ )
+ except PipelineError as e:
+ self._warnings.append(PipelineWarning(
+ message=f"Comparaison échouée: {e.message}",
+ step="compare",
+ ))
+ logger.warning(f"Étape 'compare' échouée (non bloquante): {e.message}")
+
+ # Étape 4: Synchronisation CalDAV
+ try:
+ sync_result = caldav_sync_step(
+ self.caldav_client,
+ pronote_data.lessons,
+ pronote_data.homeworks,
+ pronote_data.school_events,
+ )
+ if sync_result and sync_result.status.value == "failed":
+ self._warnings.append(PipelineWarning(
+ message=f"Synchronisation CalDAV échouée: {sync_result.errors}",
+ step="sync",
+ ))
+ logger.warning("Synchronisation CalDAV échouée (non bloquante)")
+ except PipelineError as e:
+ self._warnings.append(PipelineWarning(
+ message=f"Synchronisation CalDAV échouée: {e.message}",
+ step="sync",
+ ))
+ logger.warning(f"Étape 'sync' échouée (non bloquante): {e.message}")
+
+ # Étape 5: Synthèse IA (optionnelle)
+ if self.synthesis_provider and agenda_diff:
+ try:
+ synthesis_input = SynthesisInput(
+ agenda_diff=agenda_diff,
+ messages=pronote_data.messages,
+ school_events=pronote_data.school_events,
+ target_date=pronote_data.target_date,
+ )
+ synthesis_result = synthesis_step(self.synthesis_provider, synthesis_input)
+ except PipelineError as e:
+ self._warnings.append(PipelineWarning(
+ message=f"Synthèse IA échouée: {e.message}",
+ step="synthesis",
+ ))
+ logger.warning(f"Étape 'synthesis' échouée (non bloquante): {e.message}")
+
+ # Étape 6: Construction du message XMPP
+ xmpp_message = XmppMessage(
+ target_date=pronote_data.target_date,
+ synthesis=synthesis_result.text if synthesis_result else None,
+ homeworks=pronote_data.homeworks,
+ changes=agenda_diff.changes if agenda_diff else [],
+ messages=pronote_data.messages,
+ external_info=ExternalInfo(
+ blog_articles=blog_articles,
+ pronote_messages=pronote_data.messages,
+ ) if blog_articles or pronote_data.messages else None,
+ )
+
+ # Étape 7: Envoi XMPP
+ try:
+ send_step(self.channel, xmpp_message)
+ except PipelineError as e:
+ self._warnings.append(PipelineWarning(
+ message=f"Envoi XMPP échoué: {e.message}",
+ step="send",
+ ))
+ logger.warning(f"Étape 'send' échouée (non bloquante): {e.message}")
+
+ return pronote_data, self._errors + self._warnings
+
+ except PipelineCriticalError as e:
+ logger.error(f"Erreur critique dans le pipeline: {e.message}")
+ return None, [e]
+ except Exception as e:
+ from ..utils.redaction import redact_secrets
+ safe_error = redact_secrets(str(e))
+ logger.error(f"Erreur inattendue dans le pipeline: {safe_error}")
+ return None, [PipelineCriticalError(
+ message=safe_error,
+ step="unknown",
+ )]
+
+ def get_errors(self) -> List[PipelineError]:
+ """Récupère la liste des erreurs."""
+ return self._errors
+
+ def get_warnings(self) -> List[PipelineWarning]:
+ """Récupère la liste des avertissements."""
+ return self._warnings
+```
+
+
+### 11.4 Étapes du pipeline (`pipeline/steps/`)
+
+Chaque étape du pipeline est **isolée** et peut lever des `PipelineError` ou `PipelineWarning`.
+
+#### 11.4.1 `fetch_step.py`
+
+```python
+from typing import Tuple, List
+from ..models.agenda import Lesson, Homework, SchoolEvent
+from ..models.message import Message
+from ..sources.pronote.fetcher import PronoteFetcher
+from ..utils.redaction import redact_secrets
+from .errors import PipelineError, ErrorSeverity, PipelineCriticalError
+
+
+def fetch_step(fetcher: PronoteFetcher) -> Tuple[List[Lesson], List[Homework], List[SchoolEvent], List[Message]]:
+ """
+ Étape de récupération des données Pronote.
+
+ **Logique de precedence** :
+ - Si `agenda_source=ical` et `homework_source=ical`, un seul fetch iCal suffit (les devoirs sont extraits du même flux).
+ - Si `agenda_source=ical` et `homework_source=pronotepy`, deux sources distinctes sont utilisées.
+ - La déduplication globale est effectuée après fusion des résultats.
+
+ Args:
+ fetcher: Fetcher Pronote configuré.
+
+ Returns:
+ Tuple (lessons, homeworks, school_events, messages).
+
+ Raises:
+ PipelineCriticalError: Si aucune source n'est disponible.
+ PipelineError: Si une source échoue mais qu'une autre est disponible.
+ """
+ try:
+ # Récupérer l'agenda (cours + événements scolaires)
+ lessons, agenda_homeworks = fetcher.fetch_agenda()
+ school_events = [] # À récupérer depuis iCal ou autre source
+
+ # Récupérer les devoirs selon la source configurée
+ # Si la source est iCal et que l'agenda a déjà été récupéré depuis iCal,
+ # les devoirs sont déjà inclus dans agenda_homeworks (via parsing iCal).
+ # Sinon, récupérer les devoirs depuis la source dédiée (ex: pronotepy).
+ if fetcher.homework_source.value == "pronotepy" or (
+ fetcher.homework_source.value == "auto" and fetcher.agenda_source.value != "ical"
+ ):
+ # Récupérer les devoirs depuis pronotepy
+ homework_list = fetcher.fetch_homework()
+ # Fusionner les devoirs (agenda_homeworks peut être vide si agenda_source != ical)
+ homeworks = agenda_homeworks + homework_list
+ else:
+ # Utiliser les devoirs déjà extraits de l'agenda iCal
+ homeworks = agenda_homeworks
+
+ # Récupérer les messages et informations (toujours via pronotepy)
+ messages = fetcher.fetch_messages()
+ informations = fetcher.fetch_informations()
+ messages.extend(informations)
+
+ if not lessons and not homeworks:
+ raise PipelineCriticalError(
+ message="Aucun cours ou devoir récupéré depuis Pronote",
+ step="fetch",
+ )
+
+ return lessons, homeworks, school_events, messages
+
+ except Exception as e:
+ raise PipelineError(
+ message=f"Échec de la récupération Pronote: {redact_secrets(str(e))}",
+ severity=ErrorSeverity.ERROR,
+ step="fetch",
+ recoverable=False,
+ ) from e
+```
+
+
+#### 11.4.1 bis `fetch_blog_step.py`
+
+```python
+from typing import List, Optional
+from ..models.blog import BlogArticle
+from ..sources.blog.rss import BlogRSSClient
+from ..sync.blog_state import BlogRSSState
+from .errors import PipelineError, ErrorSeverity
+
+
+def fetch_blog_step(
+ rss_client: BlogRSSClient,
+ blog_state: BlogRSSState,
+ enabled: bool = True,
+) -> List[BlogArticle]:
+ """
+ Étape de récupération des articles du blog du collège.
+
+ Args:
+ rss_client: Client RSS configuré.
+ blog_state: État local pour la déduplication.
+ enabled: Si False, retourne une liste vide.
+
+ Returns:
+ Liste des nouveaux articles.
+
+ Raises:
+ PipelineError: Si la récupération échoue (non bloquante pour le pipeline).
+ """
+ if not enabled:
+ return []
+
+ try:
+ last_guid = blog_state.get_last_guid()
+ articles = rss_client.fetch_and_parse(last_guid=last_guid)
+
+ # Mettre à jour l'état si des articles sont trouvés
+ if articles:
+ blog_state.update_last_guid(articles[0].id)
+
+ return articles
+
+ except Exception as e:
+ raise PipelineError(
+ message=f"Échec de la récupération du blog: {e}",
+ severity=ErrorSeverity.WARNING,
+ step="fetch_blog",
+ recoverable=True,
+ ) from e
+```
+
+
+#### 11.4.2 Autres étapes
+
+Les autres étapes (`normalize_step`, `compare_step`, etc.) suivent le même principe :
+- **Lever `PipelineCriticalError`** pour les erreurs bloquantes.
+- **Lever `PipelineError`** pour les erreurs non bloquantes.
+- **Retourner un résultat partiel** si possible.
+
+### 11.5 Points clés
+- **Ne jamais bloquer** : Les erreurs non critiques (ex: synthèse IA) ne bloquent pas le pipeline.
+- **Modes dégradés** :
+ - Si iCal échoue → basculer sur `pronotepy`.
+ - Si `pronotepy` échoue → basculer sur iCal.
+ - Si les deux échouent → **échec critique**.
+- **Logs clairs** : Chaque erreur est loggée avec son niveau de gravité.
+- **Retour d'erreur** : Le pipeline retourne toujours une liste des erreurs/warnings rencontrés.
+
+---
+
+## 12. Tests, fixtures et mocks
+
+### 12.1 Principes
+- **Pas de réseau en tests** : Utiliser des **mocks** pour toutes les requêtes HTTP (Pronote, CalDAV) et XMPP.
+- **Fixtures anonymisées** : Utiliser des **données réelles anonymisées** (pas de noms d'élèves, professeurs, établissements réels).
+- **Couverture élevée** : Viser **≥ 90%** de couverture (comme dans `pronote-digest`).
+- **Tests déterministes** : Les tests doivent être **reproductibles** (pas de dépendance à l'heure ou à des données externes).
+- **Vitesse** : Les tests doivent s'exécuter **rapidement** (éviter les sleeps inutiles).
+
+### 12.2 Structure des tests
+
+```
+tests/
+├── __init__.py
+├── conftest.py # Fixtures pytest partagées
+├── fixtures/ # Fichiers de fixtures (iCal, CSV, 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
+│ └── blog_rss.xml # Flux RSS du blog anonymisé
+├── unit/ # Tests unitaires
+│ ├── test_models.py # Tests des modèles Pydantic
+│ ├── test_parsing.py # Tests du parsing iCal
+│ ├── test_uid.py # Tests de normalisation des UID
+│ └── ...
+├── integration/ # Tests d'intégration
+│ ├── test_pipeline.py # Tests du pipeline complet
+│ ├── test_caldav.py # Tests de la sync CalDAV (mockée)
+│ └── ...
+└── e2e/ # Tests end-to-end
+ └── test_cli.py # Tests de l'interface CLI
+```
+
+
+### 12.3 Fixtures anonymisées
+
+#### 12.3.1 Exemple de flux iCal Pronote anonymisé (`tests/fixtures/pronote-4e.ics`)
+
+```icalendar
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Index Education//Pronote//FR
+X-WR-CALNAME:Edt Jean DUPONT 4ème A
+BEGIN:VEVENT
+UID:Edt_12345@index-education.net-20260905T120000Z-Index-Education
+DTSTAMP:20260905T120000Z
+DTSTART:20260905T080000Z
+DTEND:20260905T090000Z
+SUMMARY:Mathématiques
+CATEGORIES:Cours
+DESCRIPTION:EDUCATION MUSICALE
+Professeur : DURAND G.
+Salle : S002 Education musicale
+Contenu pédagogique :
+Pratique vocale
+Pour le 10/09/2026 :
+Réviser les chansons apprises
+Donné le 03/09/2026 :
+Apporter le cahier de chants
+END:VEVENT
+BEGIN:VEVENT
+UID:Edt_67890@index-education.net-20260905T120000Z-Index-Education
+DTSTAMP:20260905T120000Z
+DTSTART:20260905T090000Z
+DTEND:20260905T100000Z
+SUMMARY:Français
+CATEGORIES:Cours - Cours annulé
+DESCRIPTION:Français
+Professeur : Mme Martin
+Salle : 205
+Groupe : Classe entière
+Contenu pédagogique :
+Étude d'un texte littéraire.
+END:VEVENT
+STATUS:CANCELLED
+BEGIN:VEVENT
+UID:Edt_11111@index-education.net-20260905T120000Z-Index-Education
+DTSTAMP:20260905T120000Z
+DTSTART:20260920
+DTEND:20260921
+SUMMARY:Vacances de la Toussaint
+CATEGORIES:Congés
+END:VEVENT
+END:VCALENDAR
+```
+
+**Points clés** :
+- **Anonymisation** : Noms d'élèves (`Jean DUPONT`), professeurs (`M. Dupont`, `Mme Martin`), salles (`204`, `205`) et établissements sont **fictifs**.
+- **Tokens supprimés** : Les URLs ne contiennent **pas** de `icalsecurise`.
+- **Données réalistes** : Structure identique aux flux réels Pronote.
+
+
+#### 12.3.2 Exemple de fichier CSV théorique (`tests/fixtures/theoretical.csv`)
+
+```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
+...
+```
+
+
+### 12.4 Configuration pytest (`tests/conftest.py`)
+
+```python
+import pytest
+from datetime import datetime, date
+from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent, SchoolEventKind
+from pronote_sync.models.homework import Homework
+from pronote_sync.models.message import Message, MessageType
+from pronote_sync.models.pronote import PronoteData
+from pronote_sync.models.synthesis import SynthesisInput, SynthesisResult
+
+
+# --- Fixtures pour les modèles ---
+
+@pytest.fixture
+def sample_lesson():
+ """Retourne un cours Pronote de test."""
+ return Lesson(
+ id="Edt_12345@index-education.net",
+ start=datetime(2026, 9, 5, 8, 0, 0),
+ end=datetime(2026, 9, 5, 9, 0, 0),
+ subject="Mathématiques",
+ teachers=["M. Dupont"],
+ rooms=["204"],
+ status=LessonStatus.NORMAL,
+ content="Résoudre des équations du second degré.",
+ )
+
+
+@pytest.fixture
+def sample_cancelled_lesson():
+ """Retourne un cours annulé."""
+ return Lesson(
+ id="Edt_67890@index-education.net",
+ start=datetime(2026, 9, 5, 9, 0, 0),
+ end=datetime(2026, 9, 5, 10, 0, 0),
+ subject="Français",
+ teachers=["Mme Martin"],
+ rooms=["205"],
+ status=LessonStatus.CANCELLED,
+ content="Étude d'un texte littéraire.",
+ )
+
+
+@pytest.fixture
+def sample_homework():
+ """Retourne un devoir de test."""
+ return Homework(
+ id="abc123def456",
+ subject="Mathématiques",
+ teachers=["M. Dupont"],
+ assigned_on=date(2026, 9, 5),
+ due_on=date(2026, 9, 10),
+ text="Exercices 1 à 5 page 42.",
+ html="
Exercices 1 à 5 page 42.
",
+ )
+
+
+@pytest.fixture
+def sample_school_event():
+ """Retourne un événement scolaire de test."""
+ return SchoolEvent(
+ kind=SchoolEventKind.HOLIDAY,
+ label="Vacances de la Toussaint",
+ from_date=date(2026, 10, 18),
+ to_date=date(2026, 11, 3),
+ )
+
+
+@pytest.fixture
+def sample_message():
+ """Retourne un message de test."""
+ return Message(
+ id="msg_123",
+ type=MessageType.INFORMATION,
+ title="Sortie pédagogique",
+ content="Une sortie est prévue le 15 octobre.",
+ author="M. Dupont",
+ date=datetime(2026, 9, 1, 10, 0, 0),
+ read=False,
+ )
+
+
+@pytest.fixture
+def sample_pronote_data(sample_lesson, sample_homework, sample_message):
+ """Retourne un jeu de données Pronote de test."""
+ return PronoteData(
+ lessons=[sample_lesson],
+ homeworks=[sample_homework],
+ messages=[sample_message],
+ )
+
+
+# --- Fixtures pour les mocks ---
+
+@pytest.fixture
+def mock_ical_content():
+ """Retourne un contenu iCal de test."""
+ return """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Index Education//Pronote//FR
+X-WR-CALNAME:Edt Test
+BEGIN:VEVENT
+UID:Edt_12345@index-education.net-20260905T120000Z-Index-Education
+DTSTAMP:20260905T120000Z
+DTSTART:20260905T080000Z
+DTEND:20260905T090000Z
+SUMMARY:Mathématiques
+CATEGORIES:Cours
+DESCRIPTION:Matière : Mathématiques
+Professeur : M. Dupont
+Salle : 204
+Contenu pédagogique :
+Résoudre des équations du second degré.
+Pour le 10/09/2026 :
+Exercices 1 à 5 page 42.
+END:VEVENT
+END:VCALENDAR"""
+
+
+@pytest.fixture
+def mock_pronotepy_lessons():
+ """Retourne une liste de cours mockés (simule pronotepy)."""
+ class MockLesson:
+ def __init__(self, id, start, end, subject, teachers, rooms, content):
+ self.id = id
+ self.start = start
+ self.end = end
+ self.subject = subject
+ self.teachers = teachers
+ self.rooms = rooms
+ self.content = content
+
+ return [
+ MockLesson(
+ id=12345,
+ start=datetime(2026, 9, 5, 8, 0, 0),
+ end=datetime(2026, 9, 5, 9, 0, 0),
+ subject="Mathématiques",
+ teachers=[type("Teacher", (), {"name": "M. Dupont"})()],
+ rooms=[type("Room", (), {"name": "204"})()],
+ content="Résoudre des équations.",
+ ),
+ ]
+
+
+# --- Fixtures pour les mocks HTTP ---
+
+@pytest.fixture
+def mock_requests_get():
+ """Mock requests.get pour les tests iCal."""
+ import requests_mock
+
+ with requests_mock.Mocker() as m:
+ m.get(
+ "https://test.ent/pronote/ical/test.ics",
+ text=mock_ical_content(),
+ status_code=200,
+ )
+ yield m
+
+
+# --- Fixtures pour les tests de synthèse IA ---
+
+@pytest.fixture
+def mock_ai_provider():
+ """Mock un fournisseur de synthèse IA."""
+ from unittest.mock import MagicMock
+ from pronote_sync.synthesis.provider import SynthesisProvider
+
+ provider = MagicMock(spec=SynthesisProvider)
+ provider.generate.return_value = "Synthèse de test."
+ return provider
+
+
+@pytest.fixture
+def mock_failing_ai_provider():
+ """Mock un fournisseur de synthèse IA qui échoue."""
+ from unittest.mock import MagicMock
+ from pronote_sync.synthesis.provider import SynthesisProvider
+
+ provider = MagicMock(spec=SynthesisProvider)
+ provider.generate.return_value = None
+ return provider
+
+
+# --- Fixtures pour les tests XMPP ---
+
+@pytest.fixture
+def mock_xmpp_channel():
+ """Mock un canal XMPP."""
+ from unittest.mock import MagicMock
+ from pronote_sync.channels.protocol import Channel
+
+ channel = MagicMock(spec=Channel)
+ channel.name = "xmpp"
+ channel.send.return_value = True
+ return channel
+
+
+# --- Fixtures pour les tests de configuration ---
+
+@pytest.fixture
+def sample_settings():
+ """Retourne une configuration de test."""
+ from pydantic import SecretStr
+ from pronote_sync.config.settings import Settings, PronoteSettings, CalDAVSettings, XmppSettings, AISettings, AppSettings
+
+ return Settings(
+ pronote=PronoteSettings(
+ ical_url="https://test.ent/pronote/ical/test.ics",
+ username="test_user",
+ password=SecretStr("test_password"),
+ ent="test_ent",
+ agenda_source="auto",
+ homework_source="auto",
+ messages_source="pronotepy",
+ ),
+ caldav=CalDAVSettings(
+ url="https://caldav.test.com/calendars/test/",
+ username="test_user",
+ password=SecretStr("test_password"),
+ sync_past_days=7,
+ sync_future_days=30,
+ ),
+ xmpp=XmppSettings(
+ jid="test@example.com",
+ password=SecretStr("test_password"),
+ recipient="recipient@example.com",
+ ),
+ ai=AISettings(
+ enabled=True,
+ base_url="https://api.test.com/v1",
+ api_key=SecretStr("test_api_key"),
+ model="gpt-4o-mini",
+ ),
+ app=AppSettings(
+ dry_run=True,
+ log_level="DEBUG",
+ theoretical_agenda_path="./tests/fixtures/theoretical.ics",
+ ),
+ )
+
+
+# --- Exemple de test unitaire ---
+
+@pytest.mark.unittest
+def test_parse_ical_lesson(parsed_lessons):
+ """Test le parsing d'un cours depuis iCal."""
+ lessons, homeworks, school_events = parsed_lessons
+
+ assert len(lessons) == 1
+ lesson = lessons[0]
+
+ assert lesson.subject == "Mathématiques"
+ assert lesson.teachers == ["M. Dupont"]
+ assert lesson.rooms == ["204"]
+ assert lesson.start == datetime(2026, 9, 5, 8, 0, 0)
+ assert lesson.end == datetime(2026, 9, 5, 9, 0, 0)
+ assert lesson.status == LessonStatus.NORMAL
+
+
+@pytest.mark.unittest
+def test_parse_ical_homework(parsed_lessons):
+ """Test le parsing des devoirs depuis iCal."""
+ lessons, homeworks, school_events = parsed_lessons
+
+ assert len(homeworks) == 1
+ homework = homeworks[0]
+
+ assert homework.subject == "Mathématiques"
+ assert homework.due_on == date(2026, 9, 10)
+ assert "Exercices 1 à 5 page 42" in homework.text
+
+
+# --- Exemple de test d'intégration ---
+
+@pytest.mark.integration
+def test_pipeline_full(mock_requests_get, mock_caldav_client, mock_ai_provider, mock_xmpp_channel, sample_settings):
+ """Test le pipeline complet avec des mocks."""
+ from pronote_sync.pipeline.run import PipelineRunner
+ from pronote_sync.sources.pronote.fetcher import PronoteFetcher
+ from pronote_sync.sync.caldav import CalDAVClient
+ from pronote_sync.sync.diff import AgendaComparator
+ from pronote_sync.sources.theoretical.file import CSVTheoreticalAgendaProvider
+
+ # Configurer le fetcher Pronote
+ fetcher = PronoteFetcher(
+ ical_url=sample_settings.pronote.ical_url,
+ username=sample_settings.pronote.username,
+ password=sample_settings.pronote.password.get_secret_value(),
+ ent=sample_settings.pronote.ent,
+ agenda_source=sample_settings.pronote.agenda_source,
+ homework_source=sample_settings.pronote.homework_source,
+ )
+
+ # Configurer le client CalDAV
+ caldav_client = CalDAVClient(
+ url=sample_settings.caldav.url,
+ username=sample_settings.caldav.username,
+ password=sample_settings.caldav.password.get_secret_value(),
+ dry_run=True,
+ )
+
+ # Configurer le comparateur d'agenda
+ theoretical_provider = CSVTheoreticalAgendaProvider(
+ file_path=sample_settings.app.theoretical_agenda_path
+ )
+ comparator = AgendaComparator(theoretical_provider)
+
+ # Configurer le pipeline
+ runner = PipelineRunner(
+ pronote_fetcher=fetcher,
+ caldav_client=caldav_client,
+ agenda_comparator=comparator,
+ synthesis_provider=mock_ai_provider,
+ channel=mock_xmpp_channel,
+ dry_run=True,
+ )
+
+ # Exécuter le pipeline
+ pronote_data, errors = runner.run()
+
+ # Vérifications
+ assert pronote_data is not None
+ assert len(pronote_data.lessons) >= 0
+ assert len(pronote_data.homeworks) >= 0
+ assert len(errors) == 0 # Aucun erreur critique
+
+
+# --- Exemple de test de parsing des UID ---
+
+@pytest.mark.unittest
+def test_normalize_pronote_uid():
+ """Test la normalisation des UID Pronote."""
+ from pronote_sync.utils.uid import normalize_pronote_uid
+
+ # UID avec suffixe temporel
+ uid_with_suffix = "Edt_12345@index-education.net-20260905T120000Z-Index-Education"
+ normalized = normalize_pronote_uid(uid_with_suffix)
+
+ assert normalized == "Edt_12345@index-education.net"
+
+ # UID déjà normalisé
+ uid_normalized = "Edt_12345@index-education.net"
+ assert normalize_pronote_uid(uid_normalized) == uid_normalized
+
+
+# --- Exemple de test de déduplication des devoirs ---
+
+@pytest.mark.unittest
+def test_collect_homeworks():
+ """Test la collecte et déduplication des devoirs depuis des blocs de plusieurs VEVENT."""
+ from datetime import date, datetime
+ from pronote_sync.models.agenda import Lesson, LessonStatus
+ from pronote_sync.models.homework import HomeworkBlock
+ from pronote_sync.sources.pronote.ical import collect_homeworks
+
+ # Créer des cours avec des blocs de devoirs (simulant des VEVENT parsés)
+ # Cours 1 : contient un bloc "Pour le" et un bloc "Donné le" pour le même devoir
+ lesson1 = Lesson(
+ id="lesson-1",
+ start=datetime(2026, 9, 5, 8, 0, 0),
+ end=datetime(2026, 9, 5, 9, 0, 0),
+ subject="Mathématiques",
+ teachers=["M. Dupont"],
+ rooms=["204"],
+ status=LessonStatus.NORMAL,
+ content="Résoudre des équations.",
+ homework_blocks=[
+ HomeworkBlock(
+ kind="due",
+ date=date(2026, 9, 10),
+ text="Exercices 1 à 5 page 42.",
+ html="
Exercices 1 à 5 page 42.
",
+ ),
+ HomeworkBlock(
+ kind="assigned",
+ date=date(2026, 9, 5),
+ text="Exercices 1 à 5 page 42.", # Même texte que le bloc "Pour le"
+ html="
Exercices 1 à 5 page 42.
",
+ ),
+ ],
+ )
+
+ # Cours 2 : contient un bloc "Donné le" pour un autre devoir
+ lesson2 = Lesson(
+ id="lesson-2",
+ start=datetime(2026, 9, 5, 9, 0, 0),
+ end=datetime(2026, 9, 5, 10, 0, 0),
+ subject="Français",
+ teachers=["Mme Martin"],
+ rooms=["205"],
+ status=LessonStatus.NORMAL,
+ content="Étude d'un texte.",
+ homework_blocks=[
+ HomeworkBlock(
+ kind="assigned",
+ date=date(2026, 9, 5),
+ text="Lire les pages 10 à 15.",
+ html="
Lire les pages 10 à 15.
",
+ ),
+ ],
+ )
+
+ # Date cible : 10 septembre 2026
+ target_date = date(2026, 9, 10)
+
+ # Collecter et dédupliquer les devoirs
+ homeworks = collect_homeworks([lesson1, lesson2], target_date)
+
+ # Vérifications :
+ # - Le devoir "Exercices 1 à 5 page 42" doit apparaître une seule fois (dédupliqué)
+ # - Le devoir "Lire les pages 10 à 15" ne doit pas apparaître (car sa date d'échéance n'est pas le 10/09)
+ assert len(homeworks) == 1
+ assert homeworks[0].text == "Exercices 1 à 5 page 42."
+ assert homeworks[0].subject == "Mathématiques"
+ assert homeworks[0].due_on == date(2026, 9, 10)
+
+
+### 12.5 Exécution des tests
+
+#### 12.5.1 Commandes pytest
+
+| Commande | Description |
+|-----------------------------------|--------------------------------------------------|
+| `pytest` | Exécute tous les tests. |
+| `pytest tests/unit/` | Exécute uniquement les tests unitaires. |
+| `pytest tests/integration/` | Exécute uniquement les tests d'intégration. |
+| `pytest -v` | Mode verbeux (affiche les noms des tests). |
+| `pytest -x` | Arrête au premier échec. |
+| `pytest --tb=short` | Affiche une traceback courte. |
+| `pytest --cov=pronote_sync` | Mesure la couverture de code. |
+| `pytest --cov=pronote_sync --cov-report=html` | Génère un rapport HTML de couverture. |
+
+#### 12.5.2 Configuration de la couverture (`pyproject.toml`)
+
+```toml
+[tool.pytest.ini_options]
+minversion = "7.0"
+testpaths = ["tests"]
+python_files = ["test_*.py"]
+python_functions = ["test_*"]
+addopts = "-v --tb=short"
+
+[tool.coverage.run]
+source = ["pronote_sync"]
+branch = true
+
+[tool.coverage.report]
+exclude_lines = [
+ "pragma: no cover",
+ "def __repr__",
+ "raise NotImplementedError",
+ "if TYPE_CHECKING:",
+]
+fail_under = 90 # Échec si couverture < 90%
+
+[tool.coverage.html]
+directory = "coverage_html"
+```
+
+#### 12.5.3 Exemple de rapport de couverture
+
+```
+Name Stmts Miss Cover Missing
+--------------------------------------------------------------
+pronote_sync/config/settings.py 50 0 100%
+pronote_sync/models/agenda.py 100 0 100%
+pronote_sync/sources/pronote/ical.py 200 5 97% 120-125
+pronote_sync/pipeline/run.py 150 2 99% 200
+--------------------------------------------------------------
+TOTAL 1000 10 99%
+```
+
+### 12.6 Points clés
+- **Mocks** : Utiliser `requests-mock` pour les requêtes HTTP, `unittest.mock` pour les dépendances.
+- **Fixtures** : Stocker les données de test dans `tests/fixtures/`.
+- **Anonymisation** : **Jamais** de données réelles dans les fixtures.
+- **Couverture** : Viser **≥ 90%** (comme dans `pronote-digest`).
+- **Vitesse** : Les tests doivent s'exécuter en **quelques secondes** (pas de sleeps inutiles).
+- **Déterminisme** : Les tests doivent être **reproductibles** (pas de dépendance à l'heure ou à des données externes).
+
+---
+
+## 13. Checklist de sécurité
+
+### 13.1 Secrets et données sensibles
+
+| **Risque** | **Mesure de mitigation** | **Vérification** | **Statut** |
+|-------------------------------------|----------------------------------------------------------------------------------------|-------------------------------------------|------------|
+| Tokens dans le code | Utiliser `pydantic-settings` + `SecretStr` pour les variables d'environnement. | `grep -r "icalsecurise\|password\|api_key" src/` | ❌ Interdit |
+| Tokens dans les logs | Masquage systématique via `RedactingFormatter` (voir [Section 4.2](#42-implémentation)). | Tests avec `PRONOTE_ICAL_URL` contenant un token. | ✅ Obligatoire |
+| Tokens dans les erreurs | Masquage dans les messages d'erreur (voir `redact_url` et `redact_secrets`). | Tests avec URLs contenant des tokens. | ✅ Obligatoire |
+| Tokens dans les fixtures | **Anonymiser** toutes les fixtures (pas de tokens réels). | Vérification manuelle des fixtures. | ✅ Obligatoire |
+| Tokens dans les commits Git | Utiliser `.gitignore` pour `.env` et `pre-commit` pour bloquer les secrets. | `git grep "icalsecurise\|password" -- .` (contenu suivi courant) | ❌ Interdit |
+| Clés API dans le code | Toujours charger depuis les variables d'environnement. | `grep -r "api_key\s*=" src/` | ❌ Interdit |
+| Mots de passe en clair | Toujours utiliser `SecretStr` ou `getpass`. | `grep -r "password\s*=" src/` | ❌ Interdit |
+| Fichiers d'état non protégés | Appliquer `chmod 600` et exclure du Git (`.gitignore`). | `ls -la .pronote_sync_state.json` (doit être `-rw-------`) | ✅ Obligatoire |
+
+### 13.2 Validation des entrées
+
+| **Risque** | **Mesure de mitigation** | **Vérification** | **Statut** |
+|-------------------------------------|----------------------------------------------------------------------------------------|-------------------------------------------|------------|
+| Injection SQL (si SQLite) | Utiliser des requêtes paramétrées (pas de string formatting). | Revue du code utilisant SQLite. | ✅ Obligatoire |
+| Injection XMPP | Échapper les messages XMPP (slixmpp le fait automatiquement). | Tests avec des messages contenant `<`, `>`, `&`. | ✅ Obligatoire |
+| Parsing iCal malveillant | Valider que le flux contient `BEGIN:VCALENDAR` avant parsing. | Tests avec des flux invalides. | ✅ Obligatoire |
+| URLs malveillantes | Valider les URLs avec `urllib.parse` avant utilisation. | Tests avec des URLs malformées. | ✅ Obligatoire |
+| Taille des requêtes IA | Limiter la taille du prompt (`MAX_LENGTH = 800`). | Tests avec de grands prompts. | ✅ Obligatoire |
+
+### 13.3 Authentification et autorisation
+
+| **Risque** | **Mesure de mitigation** | **Vérification** | **Statut** |
+|-------------------------------------|----------------------------------------------------------------------------------------|-------------------------------------------|------------|
+| Accès non autorisé à Pronote | Utiliser les identifiants fournis par l'utilisateur (pas de hardcoding). | Revue du code d'authentification. | ✅ Obligatoire |
+| Accès non autorisé à CalDAV | Utiliser les identifiants fournis par l'utilisateur. | Revue du code CalDAV. | ✅ Obligatoire |
+| Accès non autorisé à XMPP | Utiliser les identifiants fournis par l'utilisateur. | Revue du code XMPP. | ✅ Obligatoire |
+| Accès non autorisé à l'API IA | Utiliser les clés API fournies par l'utilisateur. | Revue du code IA. | ✅ Obligatoire |
+| Stockage des secrets | **Ne jamais stocker** les secrets en base de données ou dans des fichiers non sécurisés. | Revue de l'architecture. | ✅ Obligatoire |
+
+### 13.4 Chiffrement et réseau
+
+| **Risque** | **Mesure de mitigation** | **Vérification** | **Statut** |
+|-------------------------------------|----------------------------------------------------------------------------------------|-------------------------------------------|------------|
+| Requêtes HTTP non chiffrées | Toujours utiliser `https://` pour Pronote, CalDAV, IA. | Vérification des URLs dans le code. | ✅ Obligatoire |
+| Certificats SSL invalides | Utiliser `verify=True` par défaut dans `requests` (désactiver uniquement pour les tests). | `grep -r "verify=False" src/` | ⚠️ À éviter |
+| Timeout des requêtes | Configurer des timeouts (20s pour iCal, 30s pour IA). | Revue des appels HTTP. | ✅ Obligatoire |
+| Fuites de mémoire (secrets) | **Ne jamais stocker** les secrets en mémoire plus longtemps que nécessaire. | Revue du code de gestion des secrets. | ✅ Obligatoire |
+
+### 13.5 Audit et logging
+
+| **Risque** | **Mesure de mitigation** | **Vérification** | **Statut** |
+|-------------------------------------|----------------------------------------------------------------------------------------|-------------------------------------------|------------|
+| Logs contenant des secrets | Toujours utiliser `RedactingFormatter` pour les logs. | Tests avec des secrets dans les logs. | ✅ Obligatoire |
+| Logs trop verbeux | Limiter le niveau de log à `INFO` par défaut. | Revue de la configuration des logs. | ✅ Obligatoire |
+| Logs des erreurs sensibles | Masquer les détails sensibles dans les erreurs (ex: URLs avec tokens). | Tests avec des erreurs contenant des secrets. | ✅ Obligatoire |
+| Audit des accès | **Ne pas implémenter** de logging des accès (hors scope). | Revue de l'architecture. | ⚠️ Hors scope |
+
+### 13.6 Exemple de script de vérification de sécurité
+
+```python
+#!/usr/bin/env python3
+"""
+Script de vérification de sécurité pour le projet.
+À exécuter avant chaque commit ou release.
+"""
+import subprocess
+import sys
+from pathlib import Path
+
+
+def run_command(cmd: list, description: str) -> bool:
+ """Exécute une commande et affiche le résultat.
+
+ Args:
+ cmd: Liste d'arguments pour subprocess.run (pas de shell=True pour éviter les injections).
+ description: Description de la vérification.
+ """
+ print(f"🔍 {description}...")
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ if result.returncode != 0:
+ print(f"❌ ÉCHEC: {cmd}")
+ print(result.stdout)
+ print(result.stderr)
+ return False
+
+ if result.stdout.strip():
+ print(f"⚠️ TROUVÉ:")
+ print(result.stdout)
+ return False
+
+ print(f"✅ OK")
+ return True
+
+
+def check_secrets_in_code():
+ """Vérifie qu'il n'y a pas de secrets dans le code."""
+ checks = [
+ (["grep", "-r", "icalsecurise=", "src/", "tests/", "--include=*.py"], "Tokens iCal dans le code"),
+ (["grep", "-r", "password\s*=", "src/", "tests/", "--include=*.py"], "Mots de passe en clair"),
+ (["grep", "-r", "api_key\s*=", "src/", "tests/", "--include=*.py"], "Clés API en clair"),
+ (["grep", "-r", "PRONOTE_ICAL_URL.*=", "src/", "tests/", "--include=*.py"], "URLs iCal en clair"),
+ ]
+
+ all_ok = True
+ for cmd, desc in checks:
+ if not run_command(cmd, desc):
+ all_ok = False
+
+ return all_ok
+
+
+def check_secrets_in_git():
+ """Vérifie qu'il n'y a pas de secrets dans le contenu suivi courant.
+
+ Note : `git grep` recherche dans le contenu **suivi courant** (working tree + index),
+ pas dans l'historique Git. Pour rechercher dans l'historique, utiliser :
+ - `git log -p` (pour voir les diffs complets)
+ - `git log -S 'icalsecurise='` (pour trouver les commits contenant une chaîne)
+ - Un outil dédié comme `trufflehog` ou `git-secrets --scan-history`.
+ """
+ checks = [
+ ["git", "grep", "-l", "icalsecurise=", "--", "."],
+ ["git", "grep", "-l", "password=", "--", "."],
+ ["git", "grep", "-l", "api_key=", "--", "."],
+ ]
+
+ all_ok = True
+ for cmd, desc in checks:
+ if not run_command(cmd, desc):
+ all_ok = False
+
+ return all_ok
+
+
+def check_fixtures():
+ """Vérifie que les fixtures sont anonymisées."""
+ fixtures_dir = Path("tests/fixtures")
+ if not fixtures_dir.exists():
+ print("⚠️ Dossier tests/fixtures/ introuvable")
+ return True
+
+ # Vérifier qu'il n'y a pas de tokens dans les fixtures
+ for fixture_file in fixtures_dir.glob("*"):
+ if fixture_file.suffix == ".ics":
+ content = fixture_file.read_text()
+ if "icalsecurise=" in content:
+ print(f"❌ Token trouvé dans {fixture_file}")
+ return False
+
+ print("✅ Fixtures OK")
+ return True
+
+
+def main():
+ """Exécute toutes les vérifications."""
+ print("🔒 Vérification de sécurité\n")
+
+ all_ok = True
+
+ # Vérifier les secrets dans le code
+ if not check_secrets_in_code():
+ all_ok = False
+
+ print()
+
+ # Vérifier les secrets dans Git
+ if not check_secrets_in_git():
+ all_ok = False
+
+ print()
+
+ # Vérifier les fixtures
+ if not check_fixtures():
+ all_ok = False
+
+ print()
+
+ if all_ok:
+ print("✅ Toutes les vérifications de sécurité ont réussi!")
+ return 0
+ else:
+ print("❌ Certaines vérifications de sécurité ont échoué!")
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
+```
+
+### 13.7 Outils recommandés
+
+| **Outil** | **Usage** | **Installation** |
+|-------------------------|---------------------------------------------------------------------------|--------------------------------|
+| `grep` | Recherche de secrets dans le code. | Natif (Linux/macOS) |
+| `git-secrets` | Détection de secrets dans Git (historique et contenu suivi). | `git clone https://github.com/awslabs/git-secrets.git && cd git-secrets && sudo ./install.sh` |
+| `trufflehog` | Détection de secrets dans les dépôts Git. | `pip install trufflehog` |
+| `bandit` | Analyse de sécurité Python. | `pip install bandit` |
+| `safety` | Vérification des dépendances vulnérables. | `pip install safety` |
+| `pre-commit` | Exécution de hooks avant commit (ex: `detect-secrets`). | `pip install pre-commit` |
+
+### 13.8 Exemple de configuration `pre-commit` (`.pre-commit-config.yaml`)
+
+```yaml
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v4.4.0
+ hooks:
+ - id: trailing-whitespace
+ - id: end-of-file-fixer
+ - id: check-yaml
+ - id: check-added-large-files
+
+ - repo: https://github.com/Yelp/detect-secrets
+ rev: v1.4.0
+ hooks:
+ - id: detect-secrets
+ args: [--baseline, .secrets.baseline]
+
+ - repo: https://github.com/PyCQA/bandit
+ rev: 1.7.5
+ hooks:
+ - id: bandit
+ args: [-ll, -ii]
+
+ - repo: https://github.com/awslabs/git-secrets
+ rev: master
+ hooks:
+ - id: git-secrets
+ args: [--scan, --no-index]
+
+ - repo: local
+ hooks:
+ - id: security-check
+ name: Vérification de sécurité
+ entry: python scripts/security_check.py
+ language: system
+ pass_filenames: true
+ stages: [commit]
+```
+
+### 13.9 Points clés
+- **Zéro secret en clair** : Aucun token, mot de passe ou clé API ne doit apparaître dans le code ou les logs.
+- **Masquage systématique** : Toujours masquer les secrets dans les logs et les erreurs.
+- **Anonymisation** : Toutes les fixtures doivent être anonymisées.
+- **Validation des entrées** : Toujours valider les URLs, les flux iCal et les requêtes IA.
+- **Chiffrement** : Toujours utiliser HTTPS pour les requêtes réseau.
+- **Audit régulier** : Exécuter des vérifications de sécurité avant chaque commit/release.
+
+---
+
+## 14. Checklist d'exploitation
+
+### 14.1 Déploiement
+
+| **Tâche** | **Description** | **Obligatoire** | **Statut** |
+|----------------------------------------|-----------------------------------------------------------------------------------------------------|-----------------|------------|
+| Configuration des variables d'environnement | Vérifier que toutes les variables obligatoires sont définies (voir [Section 3.1](#31-variables-denvironnement)). | ✅ Oui | |
+| Vérification des secrets | Exécuter le script de vérification de sécurité (voir [Section 13.6](#136-exemple-de-script-de-vérification-de-sécurité)). | ✅ Oui | |
+| Test en mode dry-run | Exécuter le pipeline avec `DRY_RUN=true` pour vérifier que tout fonctionne sans effet de bord. | ✅ Oui | |
+| Configuration des logs | Vérifier que les logs sont configurés avec masquage des secrets (voir [Section 4.2](#42-implémentation)). | ✅ Oui | |
+| Vérification des dépendances | Exécuter `pip check` pour vérifier que toutes les dépendances sont installées. | ✅ Oui | |
+| Configuration du cron (si planifié) | Configurer une tâche cron pour exécuter le script régulièrement (ex: tous les jours à 18h). | ⚠️ Non | |
+
+### 14.2 Configuration du cron (optionnel)
+
+Si le projet est exécuté **régulièrement** (ex: tous les jours), on peut configurer une tâche cron :
+
+```bash
+# Éditer le crontab
+crontab -e
+```
+
+Exemple de ligne cron (exécution tous les jours à 18h) :
+```
+0 18 * * * /usr/bin/python3 /chemin/vers/pronote_sync/cli/main.py >> /var/log/pronote_sync.log 2>&1
+```
+
+**Bonnes pratiques** :
+- **Rediriger les logs** vers un fichier pour le débogage.
+- **Utiliser un environnement virtuel** pour isoler les dépendances.
+- **Vérifier les permissions** : Le script doit être exécutable (`chmod +x`).
+- **Tester la commande** manuellement avant de l'ajouter au cron.
+
+### 14.3 Supervision
+
+| **Tâche** | **Description** | **Obligatoire** | **Statut** |
+|----------------------------------------|-----------------------------------------------------------------------------------------------------|-----------------|------------|
+| Vérification des logs | Surveiller les logs pour détecter les erreurs (ex: `tail -f /var/log/pronote_sync.log`). | ✅ Oui | |
+| Alertes en cas d'échec | Configurer des alertes (ex: email, notification XMPP) si le pipeline échoue. | ⚠️ Non | |
+| Rotation des logs | Configurer une rotation des logs (ex: `logrotate`) pour éviter les fichiers trop volumineux. | ⚠️ Non | |
+| Sauvegarde des données | Sauvegarder régulièrement les données synchronisées (ex: CalDAV, état local). | ⚠️ Non | |
+
+### 14.4 Exemple de configuration `logrotate` (`/etc/logrotate.d/pronote_sync`)
+
+```
+/var/log/pronote_sync.log {
+ daily
+ missingok
+ rotate 7
+ compress
+ delaycompress
+ notifempty
+ create 0640 user user
+}
+```
+
+### 14.5 Maintenance
+
+| **Tâche** | **Description** | **Fréquence** | **Statut** |
+|----------------------------------------|-----------------------------------------------------------------------------------------------------|---------------|------------|
+| Mise à jour des dépendances | Exécuter `pip list --outdated` et mettre à jour les dépendances. | Mensuelle | |
+| Vérification des secrets | Exécuter le script de vérification de sécurité. | Avant chaque mise à jour | |
+| Test du pipeline | Exécuter le pipeline en mode dry-run pour vérifier que tout fonctionne. | Avant chaque mise à jour | |
+| Sauvegarde de la configuration | Sauvegarder le fichier `.env` et les fichiers de configuration. | Avant chaque mise à jour | |
+| Revue des logs | Vérifier les logs pour détecter des erreurs récurrentes. | Hebdomadaire | |
+
+### 14.6 Dépannage
+
+#### 14.6.1 Problèmes courants
+
+| **Problème** | **Cause possible** | **Solution** |
+|---------------------------------------|------------------------------------------------------------------------------------|------------------------------------------------------------------------------|
+| Échec de la récupération iCal | Token `icalsecurise` expiré ou invalide. | Régénérer le token depuis Pronote. |
+| Échec de la connexion Pronote (`pronotepy`) | Identifiants incorrects ou ENT non supporté. | Vérifier `PRONOTE_USERNAME`, `PRONOTE_PASSWORD`, `PRONOTE_ENT`. |
+| Échec de la connexion CalDAV | URL, identifiant ou mot de passe CalDAV incorrect. | Vérifier `CALDAV_URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`. |
+| Échec de la connexion XMPP | Identifiant ou mot de passe XMPP incorrect. | Vérifier `XMPP_JID`, `XMPP_PASSWORD`. |
+| Échec de la synthèse IA | Clé API IA invalide ou modèle non disponible. | Vérifier `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`. |
+| Aucun cours récupéré | Flux iCal vide ou `pronotepy` non configuré. | Vérifier `PRONOTE_ICAL_URL` ou les identifiants `pronotepy`. |
+| Doublons dans les devoirs | Problème de déduplication. | Vérifier la logique de déduplication (voir [Section 5.1.4](#514-déduplication-des-devoirs)). |
+| Synchronisation CalDAV lente | Trop d'événements à synchroniser. | Réduire `SYNC_PAST_DAYS` ou `SYNC_FUTURE_DAYS`. |
+
+#### 14.6.2 Commandes de débogage
+
+| **Commande** | **Description** |
+|---------------------------------------|-----------------------------------------------------------------------------------------------------|
+| `python -m pronote_sync.cli.main --dry-run --log-level DEBUG` | Exécute le pipeline en mode dry-run avec des logs détaillés. |
+| `python -c "from pronote_sync.sources.pronote.ical import fetch_ical; print(fetch_ical('file://tests/fixtures/pronote-4e.ics'))"` | Teste le parsing d'un fichier iCal local. |
+| `python -c "from pronote_sync.config.settings import settings; print(settings)"` | Affiche la configuration chargée. |
+| `python -c "import caldav; print(caldav.__version__)"` | Vérifie la version de la bibliothèque CalDAV. |
+| `python -c "import slixmpp; print(slixmpp.__version__)"` | Vérifie la version de la bibliothèque XMPP. |
+
+### 14.7 Points clés
+- **Test en dry-run** : Toujours tester avec `DRY_RUN=true` avant de passer en production.
+- **Vérification des secrets** : Exécuter le script de vérification de sécurité avant chaque déploiement.
+- **Supervision** : Surveiller les logs pour détecter les erreurs.
+- **Maintenance** : Mettre à jour régulièrement les dépendances.
+- **Dépannage** : Utiliser les commandes de débogage pour diagnostiquer les problèmes.
+
+---
+
+## 15. Limites connues et risques
+
+### 15.1 Limites techniques
+
+| **Limite** | **Description** | **Impact** | **Solution proposée** |
+|-----------------------------------------|-----------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------|---------------------------------------------------------------------------------------|
+| **Pas d'API officielle Pronote** | Pronote ne fournit pas d'API publique pour les élèves/parents. | Dépendance au flux iCal ou à `pronotepy` (reverse-engineering). | Utiliser le flux iCal officiel en priorité. |
+| **Flux iCal incomplet** | Certains établissements désactivent l'export des devoirs dans iCal. | Impossible de récupérer les devoirs via iCal. | Basculer sur `pronotepy` pour les devoirs. |
+| **`pronotepy` en maintenance** | `pronotepy` est en mode maintenance (bugfixes uniquement). | Risque de cassure si Pronote met à jour son protocole. | Surveiller les issues GitHub de `pronotepy`. |
+| **Messages non disponibles dans iCal** | Les messages, discussions et informations ne sont **pas** dans le flux iCal. | Impossible de récupérer ces données sans `pronotepy`. | Utiliser `pronotepy` pour les messages. |
+| **CalDAV : support variable** | Certains serveurs CalDAV ont des limitations (ex: pas de sync-token). | Synchronisation moins efficace. | Utiliser un état local (SQLite/JSON) pour compenser. |
+| **XMPP : serveurs variés** | Les serveurs XMPP ont des configurations différentes (ex: authentification, TLS). | Problèmes de compatibilité possibles. | Tester avec le serveur XMPP cible avant déploiement. |
+| **IA : coûts et latence** | Les API IA peuvent être coûteuses et lentes. | Synthèse IA peut être désactivée ou lente. | Limiter la taille du prompt et utiliser un timeout. |
+| **Python 3.13.5+** | Le projet nécessite Python ≥ 3.13.5. | Incompatibilité avec les anciennes versions de Python. | Documenter clairement la version requise. |
+
+### 15.2 Risques de sécurité
+
+| **Risque** | **Description** | **Impact** | **Mitigation** |
+|-----------------------------------------|-----------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------|---------------------------------------------------------------------------------------|
+| **Fuites de tokens** | Un token `icalsecurise` ou une clé API pourrait fuir dans les logs ou le code. | Accès non autorisé à Pronote ou à l'API IA. | Masquage systématique des secrets (voir [Section 4](#4-gestion-des-secrets-et-redaction)). |
+| **Reverse-engineering de Pronote** | `pronotepy` utilise du reverse-engineering, ce qui peut violer les CGU de Pronote. | Risque juridique ou blocage par Index Éducation. | Utiliser le flux iCal officiel en priorité. |
+| **Injections XMPP** | Un message XMPP malveillant pourrait être envoyé. | Exécution de code arbitraire (si le client XMPP est vulnérable). | Utiliser `slixmpp` (maintenu) et échapper les messages. |
+| **Attaques par force brute** | Un attaquant pourrait essayer de deviner les identifiants Pronote/CalDAV/XMPP. | Accès non autorisé aux données. | Limiter les tentatives de connexion et utiliser des mots de passe robustes. |
+| **Fuites de données** | Les données Pronote (cours, devoirs, messages) sont sensibles. | Violation de la vie privée. | **Ne jamais stocker** les données en clair (sauf si nécessaire). Anonymiser les fixtures. |
+
+### 15.3 Risques opérationnels
+
+| **Risque** | **Description** | **Impact** | **Mitigation** |
+|-----------------------------------------|-----------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------|---------------------------------------------------------------------------------------|
+| **Changements dans Pronote** | Pronote pourrait modifier son format iCal ou son protocole interne. | Le projet pourrait cesser de fonctionner. | Surveiller les mises à jour de Pronote et adapter le code. |
+| **Changements dans CalDAV** | Le serveur CalDAV pourrait changer son API ou ses limitations. | La synchronisation pourrait échouer. | Tester régulièrement avec le serveur CalDAV cible. |
+| **Changements dans XMPP** | Le serveur XMPP pourrait changer sa configuration. | L'envoi des messages pourrait échouer. | Tester régulièrement avec le serveur XMPP cible. |
+| **Changements dans les API IA** | Les fournisseurs IA pourraient modifier leurs API ou leurs modèles. | La synthèse IA pourrait échouer. | Utiliser un adaptateur générique (ex: `litellm`) et gérer les erreurs. |
+| **Problèmes réseau** | Le projet dépend de connexions réseau (Pronote, CalDAV, XMPP, IA). | Le pipeline pourrait échouer. | Implémenter des timeouts et des modes dégradés. |
+| **Problèmes de performance** | Le projet pourrait être lent avec de nombreux événements ou devoirs. | Expérience utilisateur dégradée. | Optimiser le code et limiter la fenêtre de synchronisation. |
+
+### 15.4 Risques juridiques
+
+| **Risque** | **Description** | **Impact** | **Mitigation** |
+|-----------------------------------------|-----------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------|---------------------------------------------------------------------------------------|
+| **Violation des CGU de Pronote** | L'utilisation de `pronotepy` pourrait violer les CGU de Pronote/Index Éducation. | Risque juridique (poursuites, blocage). | Utiliser le flux iCal officiel en priorité. Préférer une solution officielle si disponible. |
+| **Violation du RGPD** | Le projet manipule des données personnelles (noms, devoirs, messages). | Risque juridique (amendes). | **Anonymiser** toutes les données stockées ou loggées. Obtenir le consentement des utilisateurs. |
+| **Utilisation non autorisée des API IA** | Certaines API IA ont des restrictions d'usage (ex: interdiction d'usage commercial). | Risque juridique (violation des contrats). | Vérifier les CGU des fournisseurs IA et respecter leurs limitations. |
+
+### 15.5 Recommandations générales
+
+1. **Privilégier le flux iCal officiel** :
+ - Moins risqué que `pronotepy` (pas de reverse-engineering).
+ - Plus stable (format standardisé).
+
+2. **Limiter l'usage de `pronotepy`** :
+ - Utiliser uniquement pour les données **non disponibles dans iCal** (messages, informations).
+ - Surveiller les mises à jour de `pronotepy` pour détecter les cassures.
+
+3. **Gérer les erreurs avec grâce** :
+ - **Ne jamais bloquer** le pipeline pour des erreurs non critiques.
+ - Toujours fournir un **mode dégradé** (ex: envoyer le message sans synthèse IA).
+
+4. **Protéger les secrets** :
+ - **Masquer** systématiquement les tokens, mots de passe et clés API.
+ - **Ne jamais stocker** les secrets en clair dans le code ou les logs.
+
+5. **Respecter la vie privée** :
+ - **Anonymiser** toutes les données stockées ou loggées.
+ - **Ne pas collecter** de données inutiles.
+
+6. **Documenter clairement** :
+ - **Informer les utilisateurs** des risques (ex: `pronotepy` pourrait casser).
+ - **Documenter les limitations** (ex: certains établissements désactivent l'export des devoirs dans iCal).
+
+7. **Tester régulièrement** :
+ - **Tester avec les serveurs cibles** (CalDAV, XMPP) avant déploiement.
+ - **Mettre à jour les dépendances** régulièrement.
+
+---
+
+## Annexes
+
+### Glossaire
+
+| **Terme** | **Définition** |
+|-------------------------|-----------------------------------------------------------------------------------------------------|
+| **Pronote** | Logiciel de gestion de vie scolaire (collège/lycée) développé par Index Éducation. |
+| **Pronote Campus** | Version de Pronote pour l'enseignement supérieur (non ciblé par ce projet). |
+| **iCal** | Format standard pour les calendriers (RFC 5545). |
+| **CalDAV** | Protocole pour synchroniser des calendriers via HTTP. |
+| **XMPP** | Protocole de messagerie instantanée (anciennement Jabber). |
+| **UID** | Identifiant unique pour un événement iCal/CalDAV. |
+| **Dry-run** | Mode de test où aucune modification n'est appliquée (lecture seule). |
+| **Idempotence** | Propriété d'une opération qui produit le même résultat si elle est exécutée plusieurs fois. |
+| **Reverse-engineering** | Technique consistant à analyser un logiciel pour en comprendre le fonctionnement interne. |
+
+### Ressources utiles
+
+| **Ressource** | **Lien** | **Description** |
+|----------------------------------------|---------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------|
+| Pronote (officiel) | [https://www.index-education.com](https://www.index-education.com) | Site officiel de Pronote. |
+| `pronotepy` | [https://github.com/bain3/pronotepy](https://github.com/bain3/pronotepy) | Bibliothèque Python pour interagir avec Pronote. |
+| `caldav` (PyPI) | [https://pypi.org/project/caldav/](https://pypi.org/project/caldav/) | Client CalDAV pour Python. |
+| `slixmpp` | [https://github.com/poezio/slixmpp](https://github.com/poezio/slixmpp) | Bibliothèque XMPP pour Python (asyncio). |
+| `icalendar` | [https://pypi.org/project/icalendar/](https://pypi.org/project/icalendar/) | Bibliothèque pour parser/générer des fichiers iCal. |
+| `pydantic` | [https://pydantic.dev/](https://pydantic.dev/) | Bibliothèque pour la validation des données. |
+| `pydantic-settings` | [https://pydantic.dev/latest/usage/pydantic_settings/](https://pydantic.dev/latest/usage/pydantic_settings/) | Extension de Pydantic pour gérer les variables d'environnement. |
+| RFC 5545 (iCal) | [https://datatracker.ietf.org/doc/html/rfc5545](https://datatracker.ietf.org/doc/html/rfc5545) | Spécification officielle du format iCal. |
+| RFC 4791 (CalDAV) | [https://datatracker.ietf.org/doc/html/rfc4791](https://datatracker.ietf.org/doc/html/rfc4791) | Spécification officielle de CalDAV. |
+
+### C. Exemple de fichier `pyproject.toml`
+
+```toml
+[build-system]
+requires = ["setuptools>=61.0", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "pronote-sync"
+version = "0.1.0"
+description = "Synchronisation Pronote → CalDAV + XMPP"
+readme = "README.md"
+license = {text = "MIT"}
+requires-python = ">=3.13.5"
+authors = [
+ {name = "Votre Nom", email = "votre@email.com"}
+]
+keywords = ["pronote", "caldav", "xmpp", "sync", "school"]
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Intended Audience :: End Users/Desktop",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
+ "Topic :: Office/Business :: Scheduling",
+ "Topic :: Communications :: Chat",
+ "Topic :: Utilities",
+]
+dependencies = [
+ "requests>=2.31.0",
+ "icalendar>=5.0.0",
+ "caldav>=1.3.0",
+ "slixmpp>=1.8.0",
+ "pydantic>=2.0.0",
+ "pydantic-settings>=2.0.0",
+ "pronotepy>=2.15.0",
+ "openai>=1.0.0",
+ "httpx>=0.25.0",
+]
+
+[project.optional-dependencies]
+ai-litellm = ["litellm>=1.0"]
+dev = [
+ "pytest>=7.0.0",
+ "pytest-cov>=4.0.0",
+ "pytest-mock>=3.0.0",
+ "requests-mock>=1.11.0",
+ "aioresponses>=0.7.0",
+ "bandit>=1.7.0",
+ "safety>=2.0.0",
+ "pre-commit>=3.0.0",
+ "detect-secrets>=1.4.0",
+]
+
+[project.scripts]
+pronote-sync = "pronote_sync.cli.main:main"
+
+[project.urls]
+Homepage = "https://github.com/votre-utilisateur/pronote-sync"
+Documentation = "https://github.com/votre-utilisateur/pronote-sync#readme"
+Repository = "https://github.com/votre-utilisateur/pronote-sync"
+Issues = "https://github.com/votre-utilisateur/pronote-sync/issues"
+
+[tool.setuptools.packages.find]
+where = ["."]
+include = ["pronote_sync*"]
+
+[tool.pytest.ini_options]
+minversion = "7.0"
+testpaths = ["tests"]
+python_files = ["test_*.py"]
+python_functions = ["test_*"]
+addopts = "-v --tb=short"
+
+[tool.coverage.run]
+source = ["pronote_sync"]
+branch = true
+
+[tool.coverage.report]
+exclude_lines = [
+ "pragma: no cover",
+ "def __repr__",
+ "raise NotImplementedError",
+ "if TYPE_CHECKING:",
+]
+fail_under = 90
+
+[tool.bandit]
+exclude_dirs = ["tests", "venv"]
+skips = ["B101"] # Ignorer les assertions (utilisées dans les tests)
+
+[tool.ruff]
+line-length = 100
+target-version = "py313"
+select = [
+ "E", # pycodestyle errors
+ "W", # pycodestyle warnings
+ "F", # Pyflakes
+ "I", # isort
+ "B", # flake8-bugbear
+ "C4", # flake8-comprehensions
+ "UP", # pyupgrade
+]
+ignore = [
+ "E501", # line too long (géré par line-length)
+]
+
+[tool.mypy]
+python_version = "3.13"
+warn_return_any = true
+warn_unused_configs = true
+disallow_untyped_defs = true
+strict = true
+
+---
+
+## Conclusion
+
+Ce guide fournit une **base architecturale et technique solide** pour développer un outil Python de synchronisation Pronote → CalDAV + XMPP, inspiré du projet TypeScript `pronote-digest`. Il couvre :
+
+- **L'architecture** : Pipeline modulaire, injectable et testable.
+- **Les spécificités Pronote** : Parsing des flux iCal, déduplication des devoirs, normalisation des UID.
+- **Les intégrations** : CalDAV (synchronisation différentielle), XMPP (envoi de messages structurés), IA (synthèse optionnelle).
+- **La sécurité** : Gestion des secrets, masquage des logs, validation des entrées.
+- **Les tests** : Mocks, fixtures anonymisées, couverture élevée.
+- **L'exploitation** : Déploiement, supervision, maintenance.
+
+### Prochaines étapes
+1. **Créer le dépôt** : Initialiser un nouveau dépôt Python avec la structure proposée.
+2. **Implémenter le cœur** : Commencer par les modules `models/`, `sources/pronote/ical.py` et `utils/`.
+3. **Ajouter les tests** : Écrire des tests unitaires pour chaque module dès le début.
+4. **Configurer CI/CD** : Mettre en place GitHub Actions pour exécuter les tests et vérifier la sécurité.
+5. **Tester en conditions réelles** : Utiliser des flux iCal Pronote anonymisés pour valider le parsing.
+
+> **⚠️ Rappel** : Ce guide est **volontairement détaillé** pour préserver les connaissances acquises sur les spécificités de Pronote. Certaines sections (ex: parsing iCal) contiennent des **observations précises** issues de l'analyse du code TypeScript existant. **Ne pas sous-estimer l'importance de ces détails** : ils sont critiques pour un fonctionnement fiable du projet.
+
+---
+
+## Annexes
+
+| **Terme** | **Définition** |
+|-------------------------|-----------------------------------------------------------------------------------------------------|
+| **Pronote** | Logiciel de gestion de vie scolaire (collège/lycée) développé par Index Éducation. |
+| **Pronote Campus** | Version de Pronote pour l'enseignement supérieur (non ciblé par ce projet). |
+| **iCal** | Format standard pour les calendriers (RFC 5545). |
+| **CalDAV** | Protocole pour synchroniser des calendriers via HTTP. |
+| **XMPP** | Protocole de messagerie instantanée (anciennement Jabber). |
+| **UID** | Identifiant unique pour un événement iCal/CalDAV. |
+| **Dry-run** | Mode de test où aucune modification n'est appliquée (lecture seule). |
+| **Idempotence** | Propriété d'une opération qui produit le même résultat si elle est exécutée plusieurs fois. |
+| **Reverse-engineering** | Technique consistant à analyser un logiciel pour en comprendre le fonctionnement interne. |
+
+### B. Ressources utiles
+
+| **Ressource** | **Lien** | **Description** |
+|----------------------------------------|---------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------|
+| Pronote (officiel) | [https://www.index-education.com](https://www.index-education.com) | Site officiel de Pronote. |
+| `pronotepy` | [https://github.com/bain3/pronotepy](https://github.com/bain3/pronotepy) | Bibliothèque Python pour interagir avec Pronote. |
+| `caldav` (PyPI) | [https://pypi.org/project/caldav/](https://pypi.org/project/caldav/) | Client CalDAV pour Python. |
+| `slixmpp` | [https://github.com/poezio/slixmpp](https://github.com/poezio/slixmpp) | Bibliothèque XMPP pour Python (asyncio). |
+| `icalendar` | [https://pypi.org/project/icalendar/](https://pypi.org/project/icalendar/) | Bibliothèque pour parser/générer des fichiers iCal. |
+| `pydantic` | [https://pydantic.dev/](https://pydantic.dev/) | Bibliothèque pour la validation des données. |
+| RFC 5545 (iCal) | [https://datatracker.ietf.org/doc/html/rfc5545](https://datatracker.ietf.org/doc/html/rfc5545) | Spécification officielle du format iCal. |
+| RFC 4791 (CalDAV) | [https://datatracker.ietf.org/doc/html/rfc4791](https://datatracker.ietf.org/doc/html/rfc4791) | Spécification officielle de CalDAV.
+
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..290ab5d
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,276 @@
+# TODO — Développement de `pronote-sync` (Pronote → CalDAV + XMPP)
+
+> Plan de développement dérivé du `GUIDE_DEV_PYTHON.md`. Package Python : `pronote_sync`.
+> Pipeline : récupération Pronote (iCal / pronotepy) → blog RSS → comparaison agenda théorique → sync CalDAV → synthèse IA → message XMPP.
+> Contraintes transverses : Python ≥ 3.13.5, Pydantic v2 + pydantic-settings, injection par `typing.Protocol`, tests sans réseau, idempotence, mode dégradé, masquage systématique des secrets, coverage ≥ 90 %.
+
+---
+
+## M1. Échafaudage et outillage — Priorité : Haute
+
+Mettre en place le dépôt, l'environnement, l'arborescence du package et la chaîne d'outils (lint/type/test/pré-commit).
+
+- [ ] Créer l'environnement virtuel Python (≥ 3.13.5) et l'activer (`python -m venv venv`).
+- [ ] Créer `pyproject.toml` d'après l'Annexe C (projet `pronote-sync`, `requires-python = ">=3.13.5"`, dépendances, extras `dev` et `ai-litellm`, script console `pronote-sync`).
+- [ ] Configurer ruff (`line-length = 100`, `target-version = "py313"`, règles E/W/F/I/B/C4/UP), mypy (`strict`), bandit et coverage (`fail_under = 90`) dans `pyproject.toml`.
+- [ ] Créer `.gitignore` (venv, `__pycache__`, `.env`, `.blog_rss_state.json`, `.coverage`, artefacts `.ics` temporaires).
+- [ ] Créer l'arborescence `pronote_sync/` avec `__init__.py` dans chaque package (`config`, `models`, `sources/pronote`, `sources/blog`, `sources/theoretical`, `sync`, `synthesis`, `channels`, `pipeline/steps`, `cli`, `utils`, `tests`).
+- [ ] Ajouter la configuration pre-commit (ruff, mypy, bandit, detect-secrets, `trailing-whitespace`, `end-of-file`).
+- [ ] Installer le projet en mode éditable : `pip install -e ".[dev]"`.
+- [ ] Créer le commit initial (scaffold + `.gitignore`, sans aucun secret).
+
+### Critères d'acceptation
+- Le package `pronote_sync` est importable sans erreur.
+- `ruff check .`, `mypy .` et `pytest` s'exécutent sans erreur d'import.
+- `pre-commit run --all-files` réussit.
+- Le dépôt ne contient aucun secret (vérifié par detect-secrets).
+
+---
+
+## M2. Configuration et gestion des secrets — Priorité : Haute
+
+Implémenter la configuration Pydantic Settings, le masquage des secrets et la journalisation sûre.
+
+- [ ] Créer `config/settings.py` : `PronoteSettings`, `CalDAVSettings`, `XmppSettings`, `AISettings`, `AppSettings`, `Settings` (§3.2) avec `SecretStr` et prefixes d'env.
+- [ ] Créer `config/env.py` pour le chargement du `.env` (`SettingsConfigDict(env_file=".env")`).
+- [ ] Créer `.env.example` complet (toutes variables obligatoires §3.1.1 + optionnelles §3.1.2).
+- [ ] Créer `utils/redaction.py` : `redact_url`, `redact_secrets`, `redact_exception` (§4.2.1).
+- [ ] Créer `utils/logging.py` : `setup_logging` + `RedactingFormatter` masquant les secrets dans messages et args (§4.2.2).
+- [ ] Créer `utils/uid.py` : `normalize_uid` (suppression des suffixes temporels des UID Pronote).
+- [ ] Vérifier qu'aucun `SecretStr` n'est affiché en clair via `str()`/`print`.
+
+### Critères d'acceptation
+- `from pronote_sync.config.settings import settings` fonctionne et charge `.env`.
+- `redact_secrets("...icalsecurise=TOKEN...")` masque le token et l'URL.
+- Les logs ne contiennent jamais de token, mot de passe ou clé API (même en DEBUG).
+
+---
+
+## M3. Modèles de données Pydantic — Priorité : Haute
+
+Définir tous les modèles de domaine, immuables pour les contrats, mutables pour les résultats de travail.
+
+- [ ] Créer `models/agenda.py` : `Status`, `LessonStatus`, `HomeworkBlock`, `Lesson` (frozen), `SchoolEventKind`, `SchoolEvent`, `TheoreticalLesson`.
+- [ ] Créer `models/homework.py` : `Homework` (frozen, `id` = hachage stable).
+- [ ] Créer `models/message.py` : `MessageType`, `Message` (frozen).
+- [ ] Créer `models/diff.py` : `AgendaChangeType`, `AgendaChange` (frozen), `AgendaDiff` (frozen).
+- [ ] Créer `models/pronote.py` : `PronoteData` (mutable).
+- [ ] Créer `models/sync.py` : `CalDAVSyncStatus`, `CalDAVSyncPlan`, `CalDAVSyncResult` (mutable).
+- [ ] Créer `models/synthesis.py` : `SynthesisInput`, `SynthesisResult`.
+- [ ] Créer `models/blog.py` : `BlogArticle` (frozen), `ExternalInfo` (§5 bis.5/6).
+- [ ] Créer `models/xmpp.py` : `XmppMessage` (frozen) intégrant `external_info`.
+- [ ] Créer `models/__init__.py` ré-exportant tous les modèles.
+- [ ] Valider la sérialisation JSON (datetime/date en ISO) pour chaque modèle.
+
+### Critères d'acceptation
+- Chaque modèle s'instancie et se sérialise en JSON valide.
+- `Lesson`, `Homework`, `Message`, `AgendaChange`, `AgendaDiff`, `XmppMessage`, `BlogArticle` sont `frozen=True`.
+- `PronoteData` et `CalDAVSyncResult` sont mutables ; tous les modèles importables via `models/__init__.py`.
+
+---
+
+## M4. Sources Pronote (iCal + pronotepy + repli) — Priorité : Haute
+
+Récupérer et normaliser l'agenda, les devoirs et les messages Pronote, avec repli entre iCal et pronotepy.
+
+- [ ] Créer `sources/pronote/ical.py` : `fetch_ical(url)` (HTTP via `requests`, erreurs redactées) et parsing iCal → `Lesson`/`Homework`/`SchoolEvent` (`icalendar`).
+- [ ] Extraire les blocs de devoirs (`HomeworkBlock`) depuis `DESCRIPTION` et dédupliquer les devoirs (clé normalisée par date).
+- [ ] Détecter les statuts (`CANCELLED`/`MOVED`) via `CATEGORIES` et `STATUS:CANCELLED`.
+- [ ] Créer `sources/pronote/client.py` : client `pronotepy` (messages, informations, discussions, sondages, et devoirs en repli) avec masquage des erreurs.
+- [ ] Créer `sources/pronote/fallback.py` : sélection de source selon `PRONOTE_*_SOURCE` (auto/ical/pronotepy) et `PronoteFetcher` unifiant `fetch_agenda`/`fetch_homework`/`fetch_messages`.
+- [ ] Implémenter le repli : iCal échoue → pronotepy ; pronotepy échoue → iCal ; les deux échouent → `PipelineCriticalError`.
+- [ ] Normaliser les UID via `utils/uid.normalize_uid` pour la stabilité des événements.
+
+### Critères d'acceptation
+- `fetch_ical` parse `tests/fixtures/pronote-4e.ics` en leçons/devoirs/événements corrects (cours annulé détecté).
+- Le client pronotepy récupère messages/devoirs (mocké).
+- Le repli bascule correctement et lève une erreur critique si aucune source disponible.
+- Aucun secret dans les messages d'erreur de fetch.
+
+---
+
+## M5. Source blog (RSS) — Priorité : Moyenne
+
+Récupérer le flux RSS du blog du collège, parser et dédupliquer les articles.
+
+- [ ] Créer `sources/blog/rss.py` : `BlogRSSClient.fetch_and_parse(known_guids)` avec `feedparser` (§5 bis.7.1).
+- [ ] Parser les dates (RFC 822 / ISO 8601) et convertir le HTML en texte brut (`BeautifulSoup` + `html.unescape`).
+- [ ] Créer `sources/blog/state.py` (ou `sync/blog_state.py`) : `BlogRSSState` (JSON : `known_guids`, `etag`, `last_modified`).
+- [ ] Implémenter la déduplication par GUID et le cache HTTP (`If-Modified-Since` / `etag`).
+- [ ] Gérer un flux invalide (`bozo`) et les exceptions sans fuite de secret (retour `[]`/warning).
+
+### Critères d'acceptation
+- `fetch_and_parse` renvoie les nouveaux articles triés par date décroissante, sans doublons.
+- L'état persiste les GUID connus entre deux appels.
+- Un flux invalide ne plante pas le pipeline (warning non bloquant).
+
+---
+
+## M6. Source agenda théorique — Priorité : Moyenne
+
+Lire l'agenda théorique (iCal ou CSV) via une interface de provider extensible.
+
+- [ ] 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.
+
+### Critères d'acceptation
+- `file.py` lit `tests/fixtures/theoretical.ics` et `theoretical.csv` en `TheoreticalLesson`.
+- Le provider renvoie une liste stable et déterministe (tri par identifiant).
+
+---
+
+## M7. Synchronisation CalDAV — Priorité : Haute
+
+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.
+- [ ] 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`).
+
+### Critères d'acceptation
+- Le plan de sync est correctement calculé (PronoteData vs état local).
+- 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`.
+
+---
+
+## M8. Comparaison avec l'agenda théorique (diff) — Priorité : Haute
+
+Comparer l'agenda réel et l'agenda théorique pour générer les ajouts/suppressions/modifications.
+
+- [ ] Créer `sync/diff.py` : `AgendaComparator` avec matching déterministe (jour + créneau avec tolérance + matière normalisée).
+- [ ] Générer `AgendaDiff` / `AgendaChange` (added / removed / modified).
+- [ ] Appliquer la politique de départage : tri par UID stable puis comparaison exacte ; première correspondance en cas de multi-match (§8.4).
+- [ ] Gérer l'absence de fichier théorique (diff vide, non bloquant).
+
+### Critères d'acceptation
+- La comparaison produit les bons `added`/`removed`/`modified`.
+- Le matching est déterministe (même entrée → même résultat).
+- Sans `THEORETICAL_AGENDA_PATH`, retourne un diff vide sans erreur.
+
+---
+
+## M9. Synthèse IA — Priorité : Moyenne
+
+Générer une synthèse optionnelle via un fournisseur IA, avec mode dégradé strict.
+
+- [ ] Créer `synthesis/provider.py` : protocole `SynthesisProvider.generate → Optional[SynthesisResult]` (ne lève jamais d'exception).
+- [ ] Créer `synthesis/openai.py` : `OpenAISynthesisProvider` (httpx, prompt système FR, max 800 car., timeout 30 s, temp 0.3).
+- [ ] Créer `synthesis/litellm.py` : `LiteLLMSynthesisProvider` (optionnel, extra `ai-litellm`).
+- [ ] Créer `synthesis/__init__.py` : factory `get_synthesis_provider(settings)` (OpenAI par défaut, litellm si `AI_PROVIDER=litellm`).
+- [ ] Mode dégradé : clé absente / timeout / exception → retour `None` (le pipeline continue sans synthèse).
+- [ ] Respecter les contraintes (3-5 phrases, ton sobre, pas d'emoji dans le texte IA).
+
+### Critères d'acceptation
+- `generate` retourne une synthèse ≤ 800 car. conforme au prompt système.
+- Clé absente ou erreur réseau → `None` (aucune exception propagée).
+- La factory renvoie le bon provider ; litellm derrière l'extra optionnel.
+
+---
+
+## M10. Canal XMPP — Priorité : Haute
+
+Construire et envoyer le message XMPP structuré via un compte bot dédié (message direct, pas de PubSub).
+
+- [ ] Créer `channels/protocol.py` : protocole `Channel` (méthode d'envoi).
+- [ ] Créer `channels/xmpp.py` : `XmppChannel` (slixmpp, message direct, compte bot dédié).
+- [ ] Implémenter `_format_message(XmppMessage)` : synthèse + liste brute des devoirs + changements + messages + infos blog (emojis 📌📅📚💬 autorisés).
+- [ ] Gérer les erreurs XMPP (reconnexion, timeout) avec masquage des secrets, non bloquant (`PipelineWarning`).
+- [ ] Créer `channels/__init__.py` : factory de canaux.
+
+### Critères d'acceptation
+- `XmppChannel.send` envoie un message direct formaté (slixmpp mocké en test).
+- Erreur XMPP → `PipelineWarning`, jamais d'exception non gérée.
+- Aucun secret dans les logs XMPP.
+
+---
+
+## M11. Orchestration du pipeline — Priorité : Haute
+
+Composer et orchestrer toutes les étapes avec gestion d'erreurs dégradée et mode dry-run.
+
+- [ ] Créer `pipeline/steps/errors.py` : `ErrorSeverity`, `PipelineError`, `PipelineWarning`, `PipelineCriticalError`.
+- [ ] Créer les étapes `pipeline/steps/` : `fetch.py`, `normalize.py`, `compare.py`, `caldav_sync.py`, `synthesis.py`, `send.py`, `fetch_blog.py`.
+- [ ] Créer `pipeline/run.py` : `PipelineRunner` (composition root) orchestrant fetch → normalize → fetch_blog → compare → caldav_sync → synthesis → send.
+- [ ] Gérer les erreurs dégradées (continuer sauf critique) et renvoyer `(PronoteData, erreurs + warns)`.
+- [ ] Implémenter le mode `dry_run` (aucune écriture CalDAV/XMPP).
+- [ ] Câbler l'injection des dépendances (Protocol + composition root), sans singleton global.
+
+### Critères d'acceptation
+- Le pipeline complet s'exécute de bout en bout (mocks) dans le bon ordre.
+- Une erreur non critique (ex : synthèse IA) n'empêche pas l'envoi XMPP.
+- `dry_run=True` n'effectue aucune écriture ; aucune source disponible → erreur critique explicite.
+
+---
+
+## M12. Point d'entrée CLI — Priorité : Haute
+
+Exposer le lancement du pipeline via une interface en ligne de commande.
+
+- [ ] Créer `cli/main.py` : `main()` (point d'entrée `pronote-sync`), args `--dry-run`, `--log-level`.
+- [ ] Initialiser les logs (`setup_logging`) et charger `settings` au démarrage.
+- [ ] Construire la composition root et lancer `PipelineRunner.run()`.
+- [ ] Gérer le code de retour et l'affichage des erreurs (redactées).
+
+### Critères d'acceptation
+- `pronote-sync --dry-run --log-level DEBUG` s'exécute sans effet de bord.
+- Le script console est installable (`[project.scripts]` dans `pyproject.toml`).
+- Les erreurs affichées ne contiennent aucun secret.
+
+---
+
+## M13. Tests et couverture — Priorité : Haute
+
+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/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`.
+- [ ] Écrire `tests/integration/` : `test_pipeline`, `test_caldav` (mocké), `test_xmpp` (mocké).
+- [ ] Écrire `tests/e2e/test_cli.py` : exécution CLI en dry-run.
+- [ ] Tests sans réseau (mocks `responses`/`aioresponses`/`pytest-mock`) ; couverture ≥ 90 %.
+- [ ] Ajouter un test négatif : les messages d'erreur ne fuient pas de secrets (`icalsecurise`, clés API, mots de passe).
+
+### Critères d'acceptation
+- `pytest` passe et `pytest --cov` atteint ≥ 90 % (`fail_under = 90`).
+- Aucun test ne fait de requête réseau réelle.
+- Le test de non-fuite de secrets passe.
+
+---
+
+## M14. Déploiement — Priorité : Moyenne
+
+Mettre en production de façon supervisée (planification, rotation des logs, vérification des secrets).
+
+- [ ] Créer une unité systemd (`pronote-sync.service` + timer) ou une ligne cron (exécution quotidienne).
+- [ ] Créer `logrotate.d/pronote_sync` (daily, rotate 7, compress, delaycompress).
+- [ ] Ajouter un script de vérification des secrets (§13.6) exécuté avant chaque déploiement.
+- [ ] Documenter la supervision (logs, alertes en cas d'échec) et la maintenance (maj dépendances, dry-run avant MAJ).
+- [ ] Vérifier `pip check` et tester le dry-run avant mise en production.
+
+### Critères d'acceptation
+- Le service/timer systemd (ou cron) lance le pipeline quotidiennement.
+- `logrotate` est configuré et valide.
+- Le dry-run passe en pré-production ; le script de secrets ne donne pas de faux négatif.
+
+---
+
+## M15. Documentation et revue finale — Priorité : Moyenne
+
+Rédiger la documentation utilisateur et finaliser le projet.
+
+- [ ] Créer `README.md` (installation, configuration `.env`, usage CLI, systemd/docker, limites, RGPD).
+- [ ] Documenter l'architecture (pipeline, modules) en résumé.
+- [ ] Ajouter `CHANGELOG` initial et la licence (MIT).
+- [ ] Revue finale : cohérence avec le guide, aucun secret documenté en clair.
+- [ ] (Optionnel) Configurer GitHub Actions CI/CD (pytest + bandit + ruff + mypy) d'après §Prochaines étapes.
+
+### Critères d'acceptation
+- `README.md` permet d'installer et de lancer le projet sans le guide.
+- La CI exécute tests + lint + sécurité.
+- Aucun secret dans la documentation.
diff --git a/pronote_sync/__init__.py b/pronote_sync/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pronote_sync/channels/__init__.py b/pronote_sync/channels/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pronote_sync/cli/__init__.py b/pronote_sync/cli/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pronote_sync/config/__init__.py b/pronote_sync/config/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pronote_sync/models/__init__.py b/pronote_sync/models/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pronote_sync/pipeline/__init__.py b/pronote_sync/pipeline/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pronote_sync/pipeline/steps/__init__.py b/pronote_sync/pipeline/steps/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pronote_sync/sources/__init__.py b/pronote_sync/sources/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pronote_sync/sources/blog/__init__.py b/pronote_sync/sources/blog/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pronote_sync/sources/pronote/__init__.py b/pronote_sync/sources/pronote/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pronote_sync/sources/theoretical/__init__.py b/pronote_sync/sources/theoretical/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pronote_sync/sync/__init__.py b/pronote_sync/sync/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pronote_sync/synthesis/__init__.py b/pronote_sync/synthesis/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pronote_sync/utils/__init__.py b/pronote_sync/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..c4873d7
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,114 @@
+[build-system]
+requires = ["setuptools>=61.0", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "pronote-sync"
+version = "0.1.0"
+description = "Synchronisation Pronote → CalDAV + XMPP"
+license = {text = "MIT"}
+requires-python = ">=3.13"
+authors = [
+ {name = "Votre Nom", email = "votre@email.com"}
+]
+keywords = ["pronote", "caldav", "xmpp", "sync", "school"]
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Intended Audience :: End Users/Desktop",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
+ "Topic :: Office/Business :: Scheduling",
+ "Topic :: Communications :: Chat",
+ "Topic :: Utilities",
+]
+dependencies = [
+ "requests>=2.31.0",
+ "icalendar>=5.0.0",
+ "caldav>=1.3.0",
+ "slixmpp>=1.8.0",
+ "pydantic>=2.0.0",
+ "pydantic-settings>=2.0.0",
+ "pronotepy>=2.15.0",
+ "openai>=1.0.0",
+ "httpx>=0.25.0",
+ "feedparser>=6.0.0",
+ "beautifulsoup4>=4.12.0",
+]
+
+[project.optional-dependencies]
+ai-litellm = ["litellm>=1.0"]
+dev = [
+ "pytest>=7.0.0",
+ "pytest-cov>=4.0.0",
+ "pytest-mock>=3.0.0",
+ "requests-mock>=1.11.0",
+ "aioresponses>=0.7.0",
+ "bandit>=1.7.0",
+ "safety>=2.0.0",
+ "pre-commit>=3.0.0",
+ "detect-secrets>=1.4.0",
+ "ruff>=0.4.0",
+ "mypy>=1.8.0",
+]
+
+[project.scripts]
+pronote-sync = "pronote_sync.cli.main:main"
+
+[project.urls]
+Homepage = "https://github.com/votre-utilisateur/pronote-sync"
+Documentation = "https://github.com/votre-utilisateur/pronote-sync#readme"
+Repository = "https://github.com/votre-utilisateur/pronote-sync"
+Issues = "https://github.com/votre-utilisateur/pronote-sync/issues"
+
+[tool.setuptools.packages.find]
+where = ["."]
+include = ["pronote_sync*"]
+
+[tool.pytest.ini_options]
+minversion = "7.0"
+testpaths = ["tests"]
+python_files = ["test_*.py"]
+python_functions = ["test_*"]
+addopts = "-v --tb=short"
+
+[tool.coverage.run]
+source = ["pronote_sync"]
+branch = true
+
+[tool.coverage.report]
+exclude_lines = [
+ "pragma: no cover",
+ "def __repr__",
+ "raise NotImplementedError",
+ "if TYPE_CHECKING:",
+]
+fail_under = 90
+
+[tool.bandit]
+exclude_dirs = ["tests", "venv"]
+skips = ["B101"] # Ignorer les assertions (utilisées dans les tests)
+
+[tool.ruff]
+line-length = 100
+target-version = "py313"
+select = [
+ "E", # pycodestyle errors
+ "W", # pycodestyle warnings
+ "F", # Pyflakes
+ "I", # isort
+ "B", # flake8-bugbear
+ "C4", # flake8-comprehensions
+ "UP", # pyupgrade
+]
+ignore = [
+ "E501", # line too long (géré par line-length)
+]
+
+[tool.mypy]
+python_version = "3.13"
+warn_return_any = true
+warn_unused_configs = true
+disallow_untyped_defs = true
+strict = true
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py
new file mode 100644
index 0000000..e69de29