Compare commits
4 Commits
feature/m6
...
feature/m7
| Author | SHA1 | Date | |
|---|---|---|---|
|
ebbe39f1f0
|
|||
|
958bb3ec5d
|
|||
|
1d26d49e74
|
|||
|
29270427ef
|
@@ -26,7 +26,7 @@ repos:
|
|||||||
name: mypy
|
name: mypy
|
||||||
entry: mypy
|
entry: mypy
|
||||||
language: python
|
language: python
|
||||||
additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "pytest>=8.0.0", "types-requests>=2.31.0", "icalendar>=5.0.0", "pronotepy>=2.15.0", "responses>=0.25.0", "pytest-mock>=3.10.0", "feedparser>=6.0.0"]
|
additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "pytest>=8.0.0", "types-requests>=2.31.0", "icalendar>=5.0.0", "pronotepy>=2.15.0", "responses>=0.25.0", "pytest-mock>=3.10.0", "feedparser>=6.0.0", "caldav>=1.3.0"]
|
||||||
types: [python]
|
types: [python]
|
||||||
pass_filenames: true
|
pass_filenames: true
|
||||||
|
|
||||||
|
|||||||
@@ -140,10 +140,10 @@
|
|||||||
"filename": "GUIDE_DEV_PYTHON.md",
|
"filename": "GUIDE_DEV_PYTHON.md",
|
||||||
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
||||||
"is_verified": true,
|
"is_verified": true,
|
||||||
"line_number": 5046,
|
"line_number": 5034,
|
||||||
"is_secret": false
|
"is_secret": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"generated_at": "2026-09-06T21:06:14Z"
|
"generated_at": "2026-09-06T22:00:48Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2712,14 +2712,95 @@ class SynthesisResult(BaseModel):
|
|||||||
### 7.1 Principes
|
### 7.1 Principes
|
||||||
- **Synchronisation différentielle** : Ne pas écraser le calendrier distant, mais **mettre à jour uniquement les événements modifiés** (décision [3](#3-synchronisation-caldav---différentielle-avec-uid-stables)).
|
- **Synchronisation différentielle** : Ne pas écraser le calendrier distant, mais **mettre à jour uniquement les événements modifiés** (décision [3](#3-synchronisation-caldav---différentielle-avec-uid-stables)).
|
||||||
- **UID stables** : Utiliser des UID normalisés pour éviter les doublons.
|
- **UID stables** : Utiliser des UID normalisés pour éviter les doublons.
|
||||||
|
- **Canonicalisation à la frontière des sources** : les UID sont normalisés une seule fois à la frontière des sources via `utils.uid.normalize_pronote_uid`, afin qu'un même cours provenant d'iCal ou de `pronotepy` produise le **même identifiant canonique** (aucun doublon ni suppression/ajout artificiel lors d'un changement de source).
|
||||||
- **Fenêtre de synchronisation** : Configurable via `SYNC_PAST_DAYS` et `SYNC_FUTURE_DAYS`.
|
- **Fenêtre de synchronisation** : Configurable via `SYNC_PAST_DAYS` et `SYNC_FUTURE_DAYS`.
|
||||||
- **Mode dry-run** : Obligatoire pour tester sans modifier le calendrier distant.
|
- **Mode dry-run** : Obligatoire pour tester sans modifier le calendrier distant.
|
||||||
- **Idempotence** : Deux exécutions identiques **doivent** produire le même état CalDAV.
|
- **Idempotence** : Deux exécutions identiques **doivent** produire le même état CalDAV.
|
||||||
|
- **Scan du calendrier distant** : La synchronisation repose sur le **scan du calendrier CalDAV distant** (les événements gérés sont relus à chaque exécution) — **aucun état local** n'est conservé (voir §7.3).
|
||||||
|
- **Plan explicite** : Le `CalDAVSyncPlan` (ajouts / mises à jour / suppressions) est **calculé explicitement avant l'exécution** de la synchronisation (voir §7.2).
|
||||||
|
- **Événements non gérés** : Les événements **non marqués** `X-PRONOTE-SYNC-MANAGED: v1` **ne sont jamais modifiés ni supprimés** : ils appartiennent à d'autres outils ou à l'utilisateur.
|
||||||
|
|
||||||
### 7.2 Client CalDAV (`sync/caldav.py`)
|
### 7.2 Client CalDAV (`sync/caldav.py`)
|
||||||
|
|
||||||
Utilisation de la bibliothèque [`caldav`](https://pypi.org/project/caldav/) (Python 3.8+, maintenue).
|
Utilisation de la bibliothèque [`caldav`](https://pypi.org/project/caldav/) (Python 3.8+, maintenue).
|
||||||
|
|
||||||
|
> **⚠️ Code illustratif** : le bloc de code ci-dessous est **illustratif** : il montre les
|
||||||
|
> règles métier de la synchronisation (marqueur, comparaison, cours annulés, dry-run).
|
||||||
|
> L'**implémentation réelle** doit s'adapter à la version de la bibliothèque `caldav`
|
||||||
|
> installée (`caldav>=1.3.0`) : l'**API réelle** documentée ci-dessous **prévaut** sur les
|
||||||
|
> anciens appels encore présents dans l'exemple (ex: `calendar(name=...)`,
|
||||||
|
> `calendar.add_event(...)`, `event.properties`, `vobject_instance`).
|
||||||
|
|
||||||
|
#### API réelle (`caldav>=1.3.0`)
|
||||||
|
|
||||||
|
- **Connexion** : `caldav.DAVClient(url, username, password)` — les paramètres proviennent
|
||||||
|
de `CalDAVSettings` (`CALDAV_URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`).
|
||||||
|
- **Résolution du calendrier** : `DAVClient.principal()` puis `principal.calendars()` ;
|
||||||
|
sélectionner le calendrier dont l'URL correspond à **`CalDAVSettings.calendar_path`**
|
||||||
|
(ex: `/pronote-sync/`). La résolution ne se fait **pas** par nom de calendrier :
|
||||||
|
`calendar_name` est abandonné au profit de `calendar_path`.
|
||||||
|
- **Lecture des événements** : `calendar.objects()` liste les objets du calendrier
|
||||||
|
(`calendar.date_search(start=..., end=...)` reste utilisable pour une fenêtre selon la
|
||||||
|
version installée).
|
||||||
|
- **Contenu iCalendar** : chaque objet expose `event.icalendar_component` (un
|
||||||
|
`icalendar.Event`) donnant accès aux propriétés (`uid`, `summary`, `dtstart`, `dtend`,
|
||||||
|
`status`, `categories`, `X-PRONOTE-SYNC-MANAGED`).
|
||||||
|
- **Ajout** : `calendar.save_event(ical_text)` crée un événement (UID normalisé et
|
||||||
|
marqueur inclus).
|
||||||
|
- **Mise à jour** : modifier les propriétés de l'`icalendar_component` puis
|
||||||
|
`event.save()` (si la version installée le supporte), sinon supprimer puis recréer sur
|
||||||
|
le même UID via `save_event()`.
|
||||||
|
- **Suppression** : `event.delete()` — **uniquement** pour les événements marqués.
|
||||||
|
|
||||||
|
**Règles métier conservées** (indépendantes de la version de `caldav`) :
|
||||||
|
- **Marqueur** : chaque événement géré porte `X-PRONOTE-SYNC-MANAGED: v1`.
|
||||||
|
- **Comparaison `_events_equal`** : ne compare que les **champs gérés** (UID, DTSTART,
|
||||||
|
DTEND, SUMMARY, DESCRIPTION, STATUS, CATEGORIES, marqueur) et ignore les propriétés
|
||||||
|
**volatiles** (`DTSTAMP`, `CREATED`, `LAST-MODIFIED`) qui changent à chaque écriture
|
||||||
|
côté serveur.
|
||||||
|
- **Cours annulés** : conservés avec `STATUS:CANCELLED` (ne pas supprimer).
|
||||||
|
- **Mode dry-run** : logue le plan sans écrire sur le calendrier distant.
|
||||||
|
|
||||||
|
#### Approche en trois phases
|
||||||
|
|
||||||
|
1. **Collecte / scan** : lister les événements du calendrier distant (fenêtre
|
||||||
|
`SYNC_PAST_DAYS` / `SYNC_FUTURE_DAYS`) et ne retenir que les événements **gérés**
|
||||||
|
(`X-PRONOTE-SYNC-MANAGED: v1`), indexés par UID normalisé (`normalize_pronote_uid`).
|
||||||
|
2. **Calcul du plan** : comparer (via `_events_equal`) les événements distants gérés avec
|
||||||
|
les données Pronote (cours, devoirs, événements scolaires) et produire un
|
||||||
|
**`CalDAVSyncPlan` explicite** (`lessons_to_add`, `lessons_to_update`,
|
||||||
|
`lessons_to_remove`, etc.) — **aucune écriture** à ce stade.
|
||||||
|
3. **Exécution** : appliquer le plan (ajouts via `calendar.save_event()`, mises à jour si
|
||||||
|
les événements diffèrent, suppressions si l'UID est absent des données Pronote) ; en
|
||||||
|
mode `dry_run`, loguer le plan **sans rien écrire**.
|
||||||
|
|
||||||
|
Exemple minimal (API réelle) :
|
||||||
|
|
||||||
|
```python
|
||||||
|
import caldav
|
||||||
|
|
||||||
|
# CalDAVSettings : url, username, password (SecretStr), calendar_path = "/pronote-sync/"
|
||||||
|
settings = None # instance de CalDAVSettings (pydantic-settings)
|
||||||
|
|
||||||
|
client = caldav.DAVClient(
|
||||||
|
url=settings.url,
|
||||||
|
username=settings.username,
|
||||||
|
password=settings.password.get_secret_value(),
|
||||||
|
)
|
||||||
|
principal = client.principal()
|
||||||
|
calendar = next(
|
||||||
|
c for c in principal.calendars()
|
||||||
|
if str(c.url).rstrip("/").endswith(settings.calendar_path.rstrip("/"))
|
||||||
|
)
|
||||||
|
|
||||||
|
for obj in calendar.objects():
|
||||||
|
vevent = obj.icalendar_component # icalendar.Event
|
||||||
|
if vevent.get("X-PRONOTE-SYNC-MANAGED") == "v1":
|
||||||
|
print(vevent.get("uid"))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exemple illustratif** (règles métier complètes — API partiellement ancienne) :
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@@ -2748,13 +2829,13 @@ class CalDAVClient:
|
|||||||
url: str,
|
url: str,
|
||||||
username: str,
|
username: str,
|
||||||
password: str,
|
password: str,
|
||||||
calendar_name: str = "Pronote",
|
calendar_path: str = "/pronote-sync/",
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
):
|
):
|
||||||
self.url = url
|
self.url = url
|
||||||
self.username = username
|
self.username = username
|
||||||
self.password = password
|
self.password = password
|
||||||
self.calendar_name = calendar_name
|
self.calendar_path = calendar_path
|
||||||
self.dry_run = dry_run
|
self.dry_run = dry_run
|
||||||
self._client: Optional[caldav.DAVClient] = None
|
self._client: Optional[caldav.DAVClient] = None
|
||||||
self._calendar: Optional[DAVCalendar] = None
|
self._calendar: Optional[DAVCalendar] = None
|
||||||
@@ -2767,40 +2848,39 @@ class CalDAVClient:
|
|||||||
password=self.password,
|
password=self.password,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Récupérer ou créer le calendrier
|
# Résoudre le calendrier via calendar_path (cf. CalDAVSettings) :
|
||||||
try:
|
# API réelle (caldav>=1.3.0) : principal.calendars() puis correspondance
|
||||||
self._calendar = self._client.calendar(name=self.calendar_name)
|
# sur l'URL du calendrier.
|
||||||
except caldav.lib.error.NotFoundError:
|
principal = self._client.principal()
|
||||||
# Créer le calendrier s'il n'existe pas
|
matches = [
|
||||||
if not self.dry_run:
|
c for c in principal.calendars()
|
||||||
self._calendar = self._client.make_calendar(
|
if str(c.url).rstrip("/").endswith(self.calendar_path.rstrip("/"))
|
||||||
name=self.calendar_name,
|
]
|
||||||
supported_calendar_components=["VEVENT"],
|
if matches:
|
||||||
)
|
self._calendar = matches[0]
|
||||||
else:
|
else:
|
||||||
|
# Pas de création automatique : la résolution se fait par chemin uniquement.
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Calendrier {self.calendar_name} introuvable et dry_run activé. "
|
f"Calendrier {self.calendar_path} introuvable"
|
||||||
"Aucune modification ne sera effectuée."
|
f"{' et dry_run activé. Aucune modification ne sera effectuée.' if self.dry_run else ' : vérifier CalDAVSettings.calendar_path.'}"
|
||||||
)
|
)
|
||||||
# Créer un calendrier fictif pour les tests
|
|
||||||
self._calendar = None
|
self._calendar = None
|
||||||
|
|
||||||
def _is_managed_event(self, event: DAVEvent) -> bool:
|
def _is_managed_event(self, event: DAVEvent) -> bool:
|
||||||
"""Vérifie si un événement est géré par l'outil."""
|
"""Vérifie si un événement est géré par l'outil."""
|
||||||
# Vérifier la présence du marqueur X-PRONOTE-SYNC-MANAGED
|
# API réelle : event.icalendar_component (icalendar.Event)
|
||||||
props = event.properties
|
vevent = event.icalendar_component
|
||||||
managed = props.get(self.MANAGED_PROPERTY, None)
|
managed = vevent.get(self.MANAGED_PROPERTY)
|
||||||
return managed and managed.value == self.MANAGED_VALUE
|
return managed is not None and str(managed) == self.MANAGED_VALUE
|
||||||
|
|
||||||
def _get_event_uid(self, event: DAVEvent) -> str:
|
def _get_event_uid(self, event: DAVEvent) -> str:
|
||||||
"""Récupère l'UID normalisé d'un événement."""
|
"""Récupère l'UID normalisé d'un événement."""
|
||||||
uid = event.vobject_instance.uid.value
|
uid = str(event.icalendar_component.get("uid"))
|
||||||
return normalize_pronote_uid(uid)
|
return normalize_pronote_uid(uid)
|
||||||
|
|
||||||
def _build_event(
|
def _build_event(
|
||||||
self,
|
self,
|
||||||
lesson: Lesson,
|
lesson: Lesson,
|
||||||
calendar_name: Optional[str] = None,
|
|
||||||
) -> DAVEvent:
|
) -> DAVEvent:
|
||||||
"""Construit un événement CalDAV à partir d'un cours Pronote."""
|
"""Construit un événement CalDAV à partir d'un cours Pronote."""
|
||||||
from icalendar import Event, vDatetime, vDate, vText, vUri
|
from icalendar import Event, vDatetime, vDate, vText, vUri
|
||||||
@@ -2842,10 +2922,6 @@ class CalDAVClient:
|
|||||||
categories.append("Déplacé")
|
categories.append("Déplacé")
|
||||||
event.add("categories", categories)
|
event.add("categories", categories)
|
||||||
|
|
||||||
# Nom du calendrier (si disponible)
|
|
||||||
if calendar_name:
|
|
||||||
event.add("x-wr-calname", calendar_name)
|
|
||||||
|
|
||||||
return DAVEvent(event)
|
return DAVEvent(event)
|
||||||
|
|
||||||
def _build_homework_event(self, homework: Homework) -> DAVEvent:
|
def _build_homework_event(self, homework: Homework) -> DAVEvent:
|
||||||
@@ -2901,7 +2977,12 @@ class CalDAVClient:
|
|||||||
"""
|
"""
|
||||||
Compare les champs gérés pour déterminer si une mise à jour est nécessaire.
|
Compare les champs gérés pour déterminer si une mise à jour est nécessaire.
|
||||||
Seuls les champs explicitement gérés par l'outil sont comparés :
|
Seuls les champs explicitement gérés par l'outil sont comparés :
|
||||||
UID, DTSTART, DTEND, SUMMARY, DESCRIPTION, STATUS, CATEGORIES, et X-PRONOTE-SYNC-MANAGED.
|
UID, DTSTART, DTEND, SUMMARY, DESCRIPTION, STATUS, CATEGORIES, et
|
||||||
|
X-PRONOTE-SYNC-MANAGED.
|
||||||
|
|
||||||
|
Les propriétés volatiles (DTSTAMP, CREATED, LAST-MODIFIED) sont
|
||||||
|
volontairement **exclues** de la comparaison : elles sont modifiées par le
|
||||||
|
serveur à chaque écriture et ne reflètent aucun changement Pronote.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
event1: Événement existant dans CalDAV.
|
event1: Événement existant dans CalDAV.
|
||||||
@@ -2911,19 +2992,17 @@ class CalDAVClient:
|
|||||||
True si les événements sont identiques pour les champs gérés, False sinon.
|
True si les événements sont identiques pour les champs gérés, False sinon.
|
||||||
"""
|
"""
|
||||||
# Comparaison des UID normalisés
|
# Comparaison des UID normalisés
|
||||||
uid1 = self._get_event_uid(event1)
|
if self._get_event_uid(event1) != self._get_event_uid(event2):
|
||||||
uid2 = self._get_event_uid(event2)
|
|
||||||
if uid1 != uid2:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Comparaison des champs gérés
|
# Comparaison des champs gérés (API réelle : icalendar_component)
|
||||||
vobj1 = event1.vobject_instance
|
vobj1 = event1.icalendar_component
|
||||||
vobj2 = event2.vobject_instance
|
vobj2 = event2.icalendar_component
|
||||||
|
|
||||||
# DTSTART et DTEND
|
# DTSTART et DTEND
|
||||||
if vobj1.get("dtstart").value != vobj2.get("dtstart").value:
|
if vobj1.get("dtstart").dt != vobj2.get("dtstart").dt:
|
||||||
return False
|
return False
|
||||||
if vobj1.get("dtend").value != vobj2.get("dtend").value:
|
if vobj1.get("dtend").dt != vobj2.get("dtend").dt:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# SUMMARY
|
# SUMMARY
|
||||||
@@ -2939,8 +3018,8 @@ class CalDAVClient:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# CATEGORIES (comparaison des listes)
|
# CATEGORIES (comparaison des listes)
|
||||||
cats1 = [str(c) for c in vobj1.get("categories", []).cats] if hasattr(vobj1.get("categories", None), "cats") else []
|
cats1 = [str(c) for c in vobj1.get("categories", [])]
|
||||||
cats2 = [str(c) for c in vobj2.get("categories", []).cats] if hasattr(vobj2.get("categories", None), "cats") else []
|
cats2 = [str(c) for c in vobj2.get("categories", [])]
|
||||||
if sorted(cats1) != sorted(cats2):
|
if sorted(cats1) != sorted(cats2):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -3178,138 +3257,25 @@ class CalDAVClient:
|
|||||||
self._calendar = None
|
self._calendar = None
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7.3 État de synchronisation (`sync/state.py`)
|
### 7.3 État de synchronisation
|
||||||
|
|
||||||
Pour éviter de synchroniser à chaque exécution tous les événements depuis le début des temps, on peut stocker un **état local** de la synchronisation.
|
**M7 ne stocke aucun état local** : il n'existe ni fichier d'état ni module
|
||||||
|
`sync/state.py`.
|
||||||
|
|
||||||
**Protéger les fichiers d'état** :
|
- **Scan du calendrier distant** : à chaque exécution, la synchronisation **scanne le
|
||||||
- **Permissions** : Appliquer `chmod 600` sur les fichiers d'état (ex: `.pronote_sync_state.json`) pour limiter l'accès au propriétaire.
|
calendrier CalDAV distant** pour retrouver les événements gérés (marqueur
|
||||||
- **Exclusion Git** : Ajouter les fichiers d'état au `.gitignore` pour éviter de les commiter.
|
`X-PRONOTE-SYNC-MANAGED: v1`), indexés par UID normalisé.
|
||||||
- **Exclusion des sauvegardes** : Exclure les fichiers d'état des sauvegardes automatiques (ex: Time Machine, rsync).
|
- **Le calendrier distant est la source de vérité** : la comparaison entre événements
|
||||||
- **Emplacement** : Stocker les fichiers d'état dans un répertoire dédié (ex: `~/.config/pronote-sync/`) hors de l'arborescence Git.
|
distants gérés et données Pronote se fait directement sur le calendrier, sans fichier
|
||||||
|
intermédiaire. Cela garantit une **idempotence naturelle** (deux exécutions identiques
|
||||||
|
produisent le même état) et supprime les risques liés à un fichier local (corruption,
|
||||||
|
perte, fuite de données, permissions `chmod 600`, exclusion `.gitignore` ou des
|
||||||
|
sauvegardes).
|
||||||
|
|
||||||
#### 7.3.1 Options pour l'état local
|
> **Évolution future** : si les performances l'exigent (calendrier très chargé, scans
|
||||||
| **Option** | **Avantages** | **Inconvénients** | **Recommandation** |
|
> trop coûteux), un **état local** (fichier JSON, SQLite ou sync-token CalDAV) pourra
|
||||||
|------------------|----------------------------------------|---------------------------------------|-----------------------------|
|
> être ajouté dans un jalon ultérieur, sans changer le contrat de la synchronisation
|
||||||
| Fichier JSON | Simple, portable, pas de dépendance | Moins performant pour les gros volumes | ✅ Pour un usage simple |
|
> (§7.1, §7.2 et §7.4 restent valables).
|
||||||
| SQLite | Performant, requêtes complexes | Dépendance supplémentaire | ✅ Pour un usage avancé |
|
|
||||||
| Sync-token CalDAV| Natif, optimisé | Pas toujours supporté par les serveurs | ⚠️ Si disponible |
|
|
||||||
|
|
||||||
#### 7.3.2 Implémentation avec JSON (`sync/state.py`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
import json
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, Optional, Any
|
|
||||||
from ..models.agenda import Lesson, Homework
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class SyncState:
|
|
||||||
"""
|
|
||||||
Gère l'état de synchronisation local (fichier JSON).
|
|
||||||
Stocke les UID et les timestamps des dernières synchronisations.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, state_file: str = ".pronote_sync_state.json"):
|
|
||||||
self.state_file = Path(state_file)
|
|
||||||
self._state: Dict[str, Any] = {
|
|
||||||
"last_sync": None,
|
|
||||||
"synced_uids": {
|
|
||||||
"lessons": set(),
|
|
||||||
"homeworks": set(),
|
|
||||||
"school_events": set(),
|
|
||||||
},
|
|
||||||
"sync_history": [],
|
|
||||||
}
|
|
||||||
self._load()
|
|
||||||
|
|
||||||
def _load(self) -> None:
|
|
||||||
"""Charge l'état depuis le fichier."""
|
|
||||||
if self.state_file.exists():
|
|
||||||
try:
|
|
||||||
with open(self.state_file, "r", encoding="utf-8") as f:
|
|
||||||
self._state = json.load(f)
|
|
||||||
# Convertir les sets en sets (JSON les stocke comme des listes)
|
|
||||||
self._state["synced_uids"] = {
|
|
||||||
k: set(v) for k, v in self._state.get("synced_uids", {}).items()
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Échec du chargement de l'état: {e}")
|
|
||||||
self._state = {
|
|
||||||
"last_sync": None,
|
|
||||||
"synced_uids": {
|
|
||||||
"lessons": set(),
|
|
||||||
"homeworks": set(),
|
|
||||||
"school_events": set(),
|
|
||||||
},
|
|
||||||
"sync_history": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
def _save(self) -> None:
|
|
||||||
"""Sauvegarde l'état dans le fichier."""
|
|
||||||
# Convertir les sets en listes pour JSON
|
|
||||||
state_to_save = {
|
|
||||||
**self._state,
|
|
||||||
"synced_uids": {
|
|
||||||
k: list(v) for k, v in self._state["synced_uids"].items()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
with open(self.state_file, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(state_to_save, f, indent=2, ensure_ascii=False)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Échec de la sauvegarde de l'état: {e}")
|
|
||||||
|
|
||||||
def mark_synced(
|
|
||||||
self,
|
|
||||||
lessons: list[Lesson],
|
|
||||||
homeworks: list[Homework],
|
|
||||||
school_events: list,
|
|
||||||
) -> None:
|
|
||||||
"""Marque les UID comme synchronisés."""
|
|
||||||
self._state["synced_uids"]["lessons"].update(lesson.id for lesson in lessons)
|
|
||||||
self._state["synced_uids"]["homeworks"].update(
|
|
||||||
f"homework-{hw.id}" for hw in homeworks
|
|
||||||
)
|
|
||||||
self._state["synced_uids"]["school_events"].update(
|
|
||||||
f"school-event-{se.label}-{se.from_date.isoformat()}" for se in school_events
|
|
||||||
)
|
|
||||||
self._state["last_sync"] = datetime.now().isoformat()
|
|
||||||
self._state["sync_history"].append({
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
"lessons": len(lessons),
|
|
||||||
"homeworks": len(homeworks),
|
|
||||||
"school_events": len(school_events),
|
|
||||||
})
|
|
||||||
self._save()
|
|
||||||
|
|
||||||
def is_synced(self, uid: str, kind: str = "lessons") -> bool:
|
|
||||||
"""Vérifie si un UID a déjà été synchronisé."""
|
|
||||||
return uid in self._state["synced_uids"].get(kind, set())
|
|
||||||
|
|
||||||
def get_last_sync(self) -> Optional[datetime]:
|
|
||||||
"""Récupère la date de la dernière synchronisation."""
|
|
||||||
if self._state["last_sync"]:
|
|
||||||
return datetime.fromisoformat(self._state["last_sync"])
|
|
||||||
return None
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
"""Efface l'état."""
|
|
||||||
self._state = {
|
|
||||||
"last_sync": None,
|
|
||||||
"synced_uids": {
|
|
||||||
"lessons": set(),
|
|
||||||
"homeworks": set(),
|
|
||||||
"school_events": set(),
|
|
||||||
},
|
|
||||||
"sync_history": [],
|
|
||||||
}
|
|
||||||
self._save()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.4 Points clés
|
### 7.4 Points clés
|
||||||
- **Différentielle** : La synchronisation compare les UID existants avec ceux à synchroniser.
|
- **Différentielle** : La synchronisation compare les UID existants avec ceux à synchroniser.
|
||||||
@@ -3317,6 +3283,9 @@ class SyncState:
|
|||||||
- **Dry-run** : Mode obligatoire pour tester sans effet de bord.
|
- **Dry-run** : Mode obligatoire pour tester sans effet de bord.
|
||||||
- **Marquage** : Les événements gérés sont marqués avec `X-PRONOTE-SYNC-MANAGED: v1` pour éviter les conflits.
|
- **Marquage** : Les événements gérés sont marqués avec `X-PRONOTE-SYNC-MANAGED: v1` pour éviter les conflits.
|
||||||
- **Cours annulés** : Conservés avec `STATUS:CANCELLED` (ne pas supprimer).
|
- **Cours annulés** : Conservés avec `STATUS:CANCELLED` (ne pas supprimer).
|
||||||
|
- **Plan explicite** : Le `CalDAVSyncPlan` est calculé avant l'exécution.
|
||||||
|
- **Pas d'état local** : La comparaison se fait avec le calendrier distant (scan).
|
||||||
|
- **Événements non gérés** : Les événements non marqués ne sont jamais modifiés ni supprimés.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -3517,24 +3486,43 @@ theoretical_lessons_sorted = sorted(
|
|||||||
lesson.subject.lower(),
|
lesson.subject.lower(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
```
|
||||||
|
|
||||||
**Exemple de matching avec départage déterministe** :
|
**Exemple de matching avec départage déterministe** :
|
||||||
```python
|
```python
|
||||||
def match_theoretical_lesson(
|
def match_theoretical_lesson(
|
||||||
real_lesson: PronoteLesson,
|
real_lesson: Lesson,
|
||||||
theoretical_lessons: list[TheoreticalLesson],
|
theoretical_events: list[TheoreticalLesson],
|
||||||
tolerance_minutes: int = 15,
|
tolerance_minutes: int = 15,
|
||||||
) -> TheoreticalLesson | None:
|
) -> TheoreticalLesson | None:
|
||||||
"""Trouve le cours théorique correspondant, avec départage déterministe."""
|
"""Trouve la leçon théorique correspondant à une leçon réelle.
|
||||||
|
|
||||||
|
:param real_lesson: Leçon réelle depuis Pronote.
|
||||||
|
:param theoretical_events: Liste des leçons théoriques candidates.
|
||||||
|
:param tolerance_minutes: Tolérance en minutes pour le créneau horaire.
|
||||||
|
:return: La leçon théorique correspondante, ou None.
|
||||||
|
:rtype: TheoreticalLesson | None
|
||||||
|
"""
|
||||||
|
real_start = real_lesson.start
|
||||||
|
real_day = real_start.weekday()
|
||||||
|
|
||||||
|
def to_minutes(t: time) -> int:
|
||||||
|
return t.hour * 60 + t.minute
|
||||||
|
|
||||||
|
# ``normalize_subject`` sera défini dans ``sync/diff.py`` (M8) ou dans le
|
||||||
|
# module théorique ; il normalise les matières pour un matching déterministe.
|
||||||
|
start_minutes = real_start.hour * 60 + real_start.minute
|
||||||
|
end_minutes = real_lesson.end.hour * 60 + real_lesson.end.minute
|
||||||
candidates = [
|
candidates = [
|
||||||
t for t in theoretical_lessons
|
t
|
||||||
if t.day_of_week == real_lesson.start.weekday()
|
for t in theoretical_events
|
||||||
and abs((t.start_time - real_lesson.start.time()).total_seconds()) <= tolerance_minutes * 60
|
if t.day_of_week == real_day
|
||||||
|
and abs(to_minutes(t.start_time) - start_minutes) <= tolerance_minutes
|
||||||
|
and abs(to_minutes(t.end_time) - end_minutes) <= tolerance_minutes
|
||||||
and normalize_subject(t.subject) == normalize_subject(real_lesson.subject)
|
and normalize_subject(t.subject) == normalize_subject(real_lesson.subject)
|
||||||
]
|
]
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return None
|
return None
|
||||||
# Tri déterministe par ID stable, puis par créneau
|
|
||||||
candidates.sort(key=lambda t: (t.id, t.start_time))
|
candidates.sort(key=lambda t: (t.id, t.start_time))
|
||||||
return candidates[0]
|
return candidates[0]
|
||||||
```
|
```
|
||||||
|
|||||||
31
TODO.md
31
TODO.md
@@ -114,14 +114,14 @@ Récupérer le flux RSS du blog du collège, parser et dédupliquer les articles
|
|||||||
|
|
||||||
Lire l'agenda théorique (JSON) via une interface de provider extensible, avec gestion de la parité des semaines (paire/impaire) et des vacances scolaires.
|
Lire l'agenda théorique (JSON) via une interface de provider extensible, avec gestion de la parité des semaines (paire/impaire) et des vacances scolaires.
|
||||||
|
|
||||||
- [ ] Créer `sources/theoretical/provider.py` : protocole `TheoreticalAgendaProvider` (§8.2).
|
- [x] Créer `sources/theoretical/provider.py` : protocole `TheoreticalAgendaProvider` (§8.2).
|
||||||
- [ ] Créer `sources/theoretical/file.py` : parser JSON → liste de `TheoreticalLesson` avec filtrage par parité de semaine (paire/impaire/toutes).
|
- [x] Créer `sources/theoretical/file.py` : parser JSON → liste de `TheoreticalLesson` avec filtrage par parité de semaine (paire/impaire/toutes).
|
||||||
- [ ] Créer `sources/theoretical/parity.py` : service `WeekParityService` déterminant la parité d'une date à partir d'une date de référence configurée.
|
- [x] Créer `sources/theoretical/parity.py` : service `WeekParityService` déterminant la parité d'une date à partir d'une date de référence configurée.
|
||||||
- [ ] Créer `sources/theoretical/holidays.py` : service `SchoolHolidayCalendar` lisant un fichier JSON de vacances scolaires (zone A) et exposant `is_holiday(date)`.
|
- [x] Créer `sources/theoretical/holidays.py` : service `SchoolHolidayCalendar` lisant un fichier JSON de vacances scolaires (zone A) et exposant `is_holiday(date)`.
|
||||||
- [ ] Implémenter le provider JSON : filtrage par parité + vacances, génération d'identifiants déterministes incluant le type de semaine.
|
- [x] Implémenter le provider JSON : filtrage par parité + vacances, génération d'identifiants déterministes incluant le type de semaine.
|
||||||
- [ ] Ajouter la configuration : `SCHOOL_HOLIDAYS_PATH`, `THEORETICAL_WEEK_ANCHOR_DATE`, `THEORETICAL_WEEK_ANCHOR_TYPE` dans `AppSettings`.
|
- [x] Ajouter la configuration : `SCHOOL_HOLIDAYS_PATH`, `THEORETICAL_WEEK_ANCHOR_DATE`, `THEORETICAL_WEEK_ANCHOR_TYPE` dans `AppSettings`.
|
||||||
- [ ] Normaliser les matières et créneaux pour le matching déterministe.
|
- [x] Normaliser les matières et créneaux pour le matching déterministe.
|
||||||
- [ ] Créer les fixtures : `tests/fixtures/theoretical.json` et `tests/fixtures/school_holidays.json`.
|
- [x] Créer les fixtures : `tests/fixtures/theoretical.json` et `tests/fixtures/school_holidays.json`.
|
||||||
|
|
||||||
### Critères d'acceptation
|
### Critères d'acceptation
|
||||||
- `file.py` lit `tests/fixtures/theoretical.json` en `TheoreticalLesson` avec filtrage par parité.
|
- `file.py` lit `tests/fixtures/theoretical.json` en `TheoreticalLesson` avec filtrage par parité.
|
||||||
@@ -136,19 +136,20 @@ Lire l'agenda théorique (JSON) via une interface de provider extensible, avec g
|
|||||||
|
|
||||||
Synchroniser différentiellement les événements Pronote vers le calendrier CalDAV, de façon idempotente.
|
Synchroniser différentiellement les événements Pronote vers le calendrier CalDAV, de façon idempotente.
|
||||||
|
|
||||||
- [ ] Créer `sync/caldav.py` : `CalDAVClient` (connexion, liste/ajout/MAJ/suppression, marqueur `X-PRONOTE-SYNC-MANAGED: v1`).
|
- [ ] Créer `sync/caldav.py` : passerelle CalDAV isolant la bibliothèque `caldav>=1.3.0` (connexion via `DAVClient`, résolution du calendrier via `calendar_path`, récupération/ajout/MAJ/suppression des événements, marqueur `X-PRONOTE-SYNC-MANAGED: v1`).
|
||||||
- [ ] Créer `sync/state.py` : état local de sync (SQLite ou JSON) assurant l'idempotence (UID connus).
|
- [ ] Calculer le `CalDAVSyncPlan` (to_add / to_update / to_remove) par UID stable, explicitement avant l'exécution de la sync.
|
||||||
- [ ] Calculer le `CalDAVSyncPlan` (to_add / to_update / to_remove) par UID stable.
|
- [ ] Implémenter l'exécution du plan : ajout, mise à jour (si modifié), suppression (si absent). En mode `dry_run`, loguer le plan sans écrire.
|
||||||
- [ ] Avant de figer le plan de sync, vérifier sur fixture anonymisée que le même cours provenant d'iCal et de `pronotepy` possède le même identifiant canonique ; corriger la normalisation à la frontière des sources si nécessaire.
|
- [ ] Vérifier sur fixture anonymisée que le même cours provenant d'iCal et de `pronotepy` possède le même identifiant canonique ; corriger la normalisation des UID dans `sources/pronote/client.py` à la frontière des sources si nécessaire.
|
||||||
- [ ] Implémenter la sync différentielle : conserver les cours annulés (`STATUS:CANCELLED`), ne pas supprimer.
|
- [ ] Implémenter la sync différentielle : conserver les cours annulés (`STATUS:CANCELLED`), ne pas supprimer.
|
||||||
- [ ] Garantir l'idempotence (2 exécutions identiques → même `CalDAVSyncResult`).
|
- [ ] Garantir l'idempotence (2 exécutions identiques → même `CalDAVSyncResult`), sans état local persistant (scan du calendrier distant).
|
||||||
- [ ] Réutiliser `BlogRSSState` pour l'état blog si pertinent (sinon `sync/blog_state.py`).
|
- [ ] Ne jamais modifier ou supprimer les événements non marqués `X-PRONOTE-SYNC-MANAGED`.
|
||||||
|
|
||||||
### Critères d'acceptation
|
### Critères d'acceptation
|
||||||
- Le plan de sync est correctement calculé (PronoteData vs état local).
|
- Le plan de sync est correctement calculé (données Pronote vs événements distants gérés).
|
||||||
- Un changement de source iCal ↔ `pronotepy` ne crée ni doublon ni suppression/ajout artificiel pour un cours équivalent.
|
- Un changement de source iCal ↔ `pronotepy` ne crée ni doublon ni suppression/ajout artificiel pour un cours équivalent.
|
||||||
- Un run dry-run n'écrit rien ; deux runs identiques donnent un résultat identique.
|
- Un run dry-run n'écrit rien ; deux runs identiques donnent un résultat identique.
|
||||||
- Les événements annulés restent (`STATUS:CANCELLED`) et sont marqués `MANAGED`.
|
- Les événements annulés restent (`STATUS:CANCELLED`) et sont marqués `MANAGED`.
|
||||||
|
- Les événements non marqués ne sont jamais modifiés ni supprimés.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ plage de dates. Le filtrage tient compte du jour de la semaine, de la parité de
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
from datetime import date, time, timedelta
|
from datetime import date, time, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
@@ -24,6 +26,25 @@ from pronote_sync.utils.redaction import redact_exception, redact_secrets
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_subject(subject: str) -> str:
|
||||||
|
"""Normalise une matière pour le matching déterministe.
|
||||||
|
|
||||||
|
Applique la normalisation Unicode NFKC, unifie les espaces (y compris
|
||||||
|
tabulations et espaces insécables), supprime la ponctuation et met la
|
||||||
|
chaîne en minuscules. Deux représentations visuellement identiques d'une
|
||||||
|
même matière produisent ainsi la même forme normalisée.
|
||||||
|
|
||||||
|
:param subject: La matière brute.
|
||||||
|
:return: La forme normalisée (NFKC, espaces unifiés, sans ponctuation, minuscule).
|
||||||
|
:rtype: str
|
||||||
|
"""
|
||||||
|
normalized = unicodedata.normalize("NFKC", subject)
|
||||||
|
normalized = re.sub(r"\s+", " ", normalized).strip()
|
||||||
|
normalized = re.sub(r"[^\w\s]", "", normalized)
|
||||||
|
normalized = re.sub(r"\s+", " ", normalized).strip()
|
||||||
|
return normalized.lower()
|
||||||
|
|
||||||
|
|
||||||
def _generate_id(entry: TheoreticalLessonEntry) -> str:
|
def _generate_id(entry: TheoreticalLessonEntry) -> str:
|
||||||
"""Génère un identifiant déterministe pour une entrée de cours.
|
"""Génère un identifiant déterministe pour une entrée de cours.
|
||||||
|
|
||||||
@@ -36,7 +57,7 @@ def _generate_id(entry: TheoreticalLessonEntry) -> str:
|
|||||||
:return: Identifiant déterministe unique.
|
:return: Identifiant déterministe unique.
|
||||||
:rtype: str
|
:rtype: str
|
||||||
"""
|
"""
|
||||||
subject_slug = entry.subject.lower().strip().replace(" ", "-")
|
subject_slug = normalize_subject(entry.subject).replace(" ", "-")
|
||||||
return f"theoretical:{entry.week}:{entry.day_of_week}:{entry.start_time}-{entry.end_time}:{subject_slug}"
|
return f"theoretical:{entry.week}:{entry.day_of_week}:{entry.start_time}-{entry.end_time}:{subject_slug}"
|
||||||
|
|
||||||
|
|
||||||
@@ -111,7 +132,7 @@ class JsonTheoreticalAgendaProvider:
|
|||||||
if effective_id in seen_ids:
|
if effective_id in seen_ids:
|
||||||
raise PronoteSyncError(
|
raise PronoteSyncError(
|
||||||
f"Conflit d'identifiant dans l'agenda théorique : "
|
f"Conflit d'identifiant dans l'agenda théorique : "
|
||||||
f"l'identifiant '{effective_id}' est utilisé par plusieurs leçons. "
|
f"l'identifiant '{redact_secrets(effective_id)}' est utilisé par plusieurs leçons. "
|
||||||
f"Fournissez des identifiants explicites uniques."
|
f"Fournissez des identifiants explicites uniques."
|
||||||
) from None
|
) from None
|
||||||
seen_ids.add(effective_id)
|
seen_ids.add(effective_id)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pronote_sync.errors import PronoteSyncError
|
from pronote_sync.errors import PronoteSyncError
|
||||||
from pronote_sync.sources.theoretical.file import JsonTheoreticalAgendaProvider
|
from pronote_sync.sources.theoretical.file import JsonTheoreticalAgendaProvider, normalize_subject
|
||||||
from pronote_sync.sources.theoretical.holidays import SchoolHolidayCalendar
|
from pronote_sync.sources.theoretical.holidays import SchoolHolidayCalendar
|
||||||
from pronote_sync.sources.theoretical.parity import WeekParityService
|
from pronote_sync.sources.theoretical.parity import WeekParityService
|
||||||
|
|
||||||
@@ -232,10 +232,10 @@ class TestJsonTheoreticalAgendaProvider:
|
|||||||
lessons2 = provider_no_parity_no_holidays.get_lessons(target_date)
|
lessons2 = provider_no_parity_no_holidays.get_lessons(target_date)
|
||||||
assert lessons1 == lessons2
|
assert lessons1 == lessons2
|
||||||
|
|
||||||
def test_even_odd_same_slot_different_ids(
|
def test_even_odd_different_subjects_different_ids(
|
||||||
self, provider_with_parity: JsonTheoreticalAgendaProvider
|
self, provider_with_parity: JsonTheoreticalAgendaProvider
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Teste que les cours even/odd sur le même créneau ont des IDs différents.
|
"""Teste que les cours even/odd sur le même créneau avec des matières différentes ont des IDs différents.
|
||||||
|
|
||||||
:assert: Les IDs des cours even et odd sont différents.
|
:assert: Les IDs des cours even et odd sont différents.
|
||||||
"""
|
"""
|
||||||
@@ -257,6 +257,59 @@ class TestJsonTheoreticalAgendaProvider:
|
|||||||
odd_ids = {lesson.id for lesson in odd_lessons}
|
odd_ids = {lesson.id for lesson in odd_lessons}
|
||||||
assert even_ids.isdisjoint(odd_ids)
|
assert even_ids.isdisjoint(odd_ids)
|
||||||
|
|
||||||
|
def test_even_odd_same_subject_different_ids(self, tmp_path: Path) -> None:
|
||||||
|
"""Vérifie que les leçons paire/impaire sur le même créneau avec la même matière ont des IDs distincts.
|
||||||
|
|
||||||
|
Utilise la même matière pour isoler la parité comme seul différenciateur.
|
||||||
|
|
||||||
|
:assert: Les IDs des cours even et odd sont différents, avec la même matière.
|
||||||
|
"""
|
||||||
|
json_content = json.dumps(
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"lessons": [
|
||||||
|
{
|
||||||
|
"week": "even",
|
||||||
|
"day_of_week": 1,
|
||||||
|
"start_time": "10:00",
|
||||||
|
"end_time": "11:00",
|
||||||
|
"subject": "Langue vivante",
|
||||||
|
"teachers": [],
|
||||||
|
"rooms": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"week": "odd",
|
||||||
|
"day_of_week": 1,
|
||||||
|
"start_time": "10:00",
|
||||||
|
"end_time": "11:00",
|
||||||
|
"subject": "Langue vivante",
|
||||||
|
"teachers": [],
|
||||||
|
"rooms": [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
file_path = tmp_path / "theoretical.json"
|
||||||
|
file_path.write_text(json_content, encoding="utf-8")
|
||||||
|
|
||||||
|
anchor_date = date(2026, 9, 1) # Tuesday
|
||||||
|
parity = WeekParityService(anchor_date, "even")
|
||||||
|
provider = JsonTheoreticalAgendaProvider(file_path=str(file_path), parity_service=parity)
|
||||||
|
|
||||||
|
# Tuesday in even week
|
||||||
|
even_tuesday = date(2026, 9, 1) # Same week as anchor (even)
|
||||||
|
odd_tuesday = date(2026, 9, 8) # One week later (odd)
|
||||||
|
|
||||||
|
even_lessons = provider.get_lessons(even_tuesday)
|
||||||
|
odd_lessons = provider.get_lessons(odd_tuesday)
|
||||||
|
|
||||||
|
assert len(even_lessons) == 1
|
||||||
|
assert len(odd_lessons) == 1
|
||||||
|
assert even_lessons[0].id != odd_lessons[0].id
|
||||||
|
# The only difference in the ID should be the week type
|
||||||
|
assert "even" in even_lessons[0].id
|
||||||
|
assert "odd" in odd_lessons[0].id
|
||||||
|
|
||||||
def test_explicit_id_preserved(
|
def test_explicit_id_preserved(
|
||||||
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
|
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -589,3 +642,81 @@ class TestJsonTheoreticalAgendaProvider:
|
|||||||
# Seule la leçon du 15 octobre (jeudi) devrait être retournée
|
# Seule la leçon du 15 octobre (jeudi) devrait être retournée
|
||||||
assert len(lessons) == 1
|
assert len(lessons) == 1
|
||||||
assert lessons[0].subject == "Sciences"
|
assert lessons[0].subject == "Sciences"
|
||||||
|
|
||||||
|
def test_normalize_subject_variants_produce_same_id(self, tmp_path: Path) -> None:
|
||||||
|
"""Vérifie que des variantes de casse, d'espacement et d'Unicode produisent le même ID.
|
||||||
|
|
||||||
|
:assert: Les variantes de la même matière produisent le même ID normalisé.
|
||||||
|
"""
|
||||||
|
# Test the normalize_subject function directly
|
||||||
|
# Note: hyphens are removed entirely (not replaced with spaces) by normalize_subject
|
||||||
|
variants = [
|
||||||
|
"Mathématiques avancées",
|
||||||
|
"mathématiques avancées",
|
||||||
|
"Mathématiques avancées",
|
||||||
|
"MATHÉMATIQUES AVANCÉES",
|
||||||
|
]
|
||||||
|
normalized = [normalize_subject(variant) for variant in variants]
|
||||||
|
# All should normalize to the same value
|
||||||
|
assert all(n == normalized[0] for n in normalized)
|
||||||
|
# Should be lowercase, no extra spaces, no punctuation
|
||||||
|
assert normalized[0] == "mathématiques avancées"
|
||||||
|
|
||||||
|
def test_collision_error_no_secret_leak(
|
||||||
|
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'une sentinelle dans un ID dupliqué n'apparaît pas dans l'erreur.
|
||||||
|
|
||||||
|
:assert: PronoteSyncError est levée et la sentinelle n'apparaît pas dans l'erreur.
|
||||||
|
"""
|
||||||
|
# Use a secret pattern that the redaction system will actually catch
|
||||||
|
# The _ISOLATED_SECRET_PATTERN looks for things like secret=value or secret: value
|
||||||
|
sentinel_value = "SENTINELLE_M6_SECRET"
|
||||||
|
# Create an ID that contains a pattern like "secret=value" which will be redacted
|
||||||
|
secret_pattern = f"secret={sentinel_value}"
|
||||||
|
|
||||||
|
# Créer un fichier JSON avec 2 entrées ayant le même ID explicite contenant la sentinelle
|
||||||
|
data = {
|
||||||
|
"version": 1,
|
||||||
|
"lessons": [
|
||||||
|
{
|
||||||
|
"id": f"theoretical-{secret_pattern}-1",
|
||||||
|
"week": "all",
|
||||||
|
"day_of_week": 0,
|
||||||
|
"start_time": "08:00",
|
||||||
|
"end_time": "09:00",
|
||||||
|
"subject": "Maths",
|
||||||
|
"teachers": [],
|
||||||
|
"rooms": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": f"theoretical-{secret_pattern}-1",
|
||||||
|
"week": "all",
|
||||||
|
"day_of_week": 1,
|
||||||
|
"start_time": "09:00",
|
||||||
|
"end_time": "10:00",
|
||||||
|
"subject": "Français",
|
||||||
|
"teachers": [],
|
||||||
|
"rooms": [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
file_path = tmp_path / "collision_test.json"
|
||||||
|
file_path.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
|
||||||
|
with caplog.at_level("ERROR"):
|
||||||
|
with pytest.raises(PronoteSyncError) as exc_info:
|
||||||
|
JsonTheoreticalAgendaProvider(
|
||||||
|
file_path=str(file_path),
|
||||||
|
parity_service=None,
|
||||||
|
holiday_calendar=None,
|
||||||
|
)
|
||||||
|
# Check that the sentinel value does not appear in the error message
|
||||||
|
assert sentinel_value not in str(exc_info.value)
|
||||||
|
# Check that the sentinel value does not appear in the logs
|
||||||
|
assert sentinel_value not in caplog.text
|
||||||
|
# Check that the secret pattern was redacted (should contain REDACTED)
|
||||||
|
assert "REDACTED" in str(exc_info.value)
|
||||||
|
# Check that the sentinel does not appear in the cause
|
||||||
|
if exc_info.value.__cause__ is not None:
|
||||||
|
assert sentinel_value not in repr(exc_info.value.__cause__)
|
||||||
|
|||||||
Reference in New Issue
Block a user